优化流程
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
output/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,16 @@
|
||||
# CADFS to CDSL
|
||||
|
||||
Converts the local CADFS FeatureScript RP corpus to self-contained CDSL 1.1,
|
||||
rebuilds runtime-eligible documents with the current CDSL engine, and compares
|
||||
the rebuilt B-rep with the source STEP.
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl scan
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --sample-id 00000173
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --limit 100 --seed 0
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline
|
||||
```
|
||||
|
||||
All stages are resumable. Use `--force` after changing converter behavior.
|
||||
The original CADFS directory is read-only; generated evidence is written under
|
||||
`cadfs_to_cdsl/output/samples/<sample_id>/`.
|
||||
@@ -0,0 +1,4 @@
|
||||
"""CADFS FeatureScript to CDSL conversion and validation pipeline."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse, json
|
||||
from pathlib import Path
|
||||
from .pipeline import load_samples, run_stage, scan, select_samples
|
||||
from .reports import generate_reports, read_json
|
||||
|
||||
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
|
||||
DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--input", type=Path, default=DEFAULT_INPUT)
|
||||
command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
if name != "scan":
|
||||
command.add_argument("--sample-id", action="append")
|
||||
command.add_argument("--offset", type=int, default=0)
|
||||
command.add_argument("--limit", type=int)
|
||||
command.add_argument("--seed", type=int)
|
||||
command.add_argument("--workers", type=int, default=1)
|
||||
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
|
||||
command.add_argument("--force", action="store_true")
|
||||
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv); args.output.mkdir(parents=True, exist_ok=True)
|
||||
if args.command == "scan":
|
||||
records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")}
|
||||
elif args.command == "report":
|
||||
manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()]
|
||||
result = generate_reports(args.output, records)
|
||||
else:
|
||||
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
|
||||
samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed)
|
||||
if args.timeout_seconds <= 0: raise ValueError("--timeout-seconds must be positive")
|
||||
records = run_stage(args.command, samples, args.output, force=args.force, compare_mode=args.compare_mode, timeout_seconds=args.timeout_seconds, workers=args.workers)
|
||||
counts: dict[str, int] = {}
|
||||
for record in records: counts[record["status"]] = counts.get(record["status"], 0) + 1
|
||||
result = {"sample_count": len(records), "statuses": counts, "summary": str(args.output / "summary.json")}
|
||||
print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True)); return 0
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
STRICT = {"surface_mm": 0.01, "bbox_mm": 0.01, "volume_relative": 1e-5, "area_relative": 1e-5}
|
||||
RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relative": 0.005}
|
||||
|
||||
|
||||
def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
|
||||
surface = report["surface"]; metrics = report["metrics"]
|
||||
maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"])
|
||||
p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"])
|
||||
checks = {
|
||||
"surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"],
|
||||
"bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
|
||||
"volume": metrics["volume_relative_error"] <= limits["volume_relative"],
|
||||
"area": metrics["surface_area_relative_error"] <= limits["area_relative"],
|
||||
"solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"],
|
||||
}
|
||||
return {"passed": all(checks.values()), "limits": limits, "checks": checks}
|
||||
|
||||
|
||||
def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]:
|
||||
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
|
||||
raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm)
|
||||
strict, rp = _assess(raw, STRICT), _assess(raw, RP)
|
||||
decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected"
|
||||
return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision}
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib, json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MODALITIES = {
|
||||
"featurescript": ("featurescript_rp", ".txt"),
|
||||
"step": ("step_abc", ".step"),
|
||||
"stl": ("stl_abc", ".stl"),
|
||||
"image": ("multiview_images_abc", ".png"),
|
||||
"annotation": ("text_annotations", ".txt"),
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sample:
|
||||
sample_id: str
|
||||
files: dict[str, str] = field(default_factory=dict)
|
||||
hashes: dict[str, str] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]: return asdict(self)
|
||||
|
||||
|
||||
def _files(root: Path, directory: str, suffix: str) -> dict[str, Path]:
|
||||
base = root / directory; result: dict[str, Path] = {}
|
||||
if not base.exists(): return result
|
||||
for path in base.rglob(f"*{suffix}"):
|
||||
if path.is_file() and path.stem.isdigit(): result[path.stem] = path
|
||||
return result
|
||||
|
||||
|
||||
def _jsonl_records(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists(): return []
|
||||
records = []
|
||||
for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
try:
|
||||
value = json.loads(line); value["_line"] = index; records.append(value)
|
||||
except json.JSONDecodeError:
|
||||
records.append({"_line": index, "_invalid": True})
|
||||
return records
|
||||
|
||||
|
||||
def _assistant_script(record: dict[str, Any]) -> str | None:
|
||||
for message in record.get("messages") or []:
|
||||
if message.get("role") == "assistant" and isinstance(message.get("content"), str) and "FeatureScript" in message["content"]:
|
||||
return message["content"]
|
||||
return None
|
||||
|
||||
|
||||
def scan_dataset(root: Path, *, include_hashes: bool = True) -> list[Sample]:
|
||||
if not root.is_dir(): raise FileNotFoundError(f"CADFS input directory does not exist: {root}")
|
||||
by_modality = {name: _files(root, directory, suffix) for name, (directory, suffix) in MODALITIES.items()}
|
||||
ids = sorted(set().union(*(set(values) for values in by_modality.values())))
|
||||
text_records = _jsonl_records(root / "CADFS_text_test.jsonl")
|
||||
image_records = _jsonl_records(root / "CADFS_image_test.jsonl")
|
||||
text_by_hash = {hashlib.sha256(script.encode()).hexdigest(): record for record in text_records if (script := _assistant_script(record))}
|
||||
samples: list[Sample] = []
|
||||
for index, sample_id in enumerate(ids):
|
||||
sample = Sample(sample_id)
|
||||
for name, values in by_modality.items():
|
||||
path = values.get(sample_id)
|
||||
if path is None: sample.diagnostics.append({"code": "missing_modality", "modality": name}); continue
|
||||
sample.files[name] = str(path)
|
||||
if include_hashes: sample.hashes[name] = sha256(path)
|
||||
fs_path = by_modality["featurescript"].get(sample_id)
|
||||
if fs_path:
|
||||
script_hash = hashlib.sha256(fs_path.read_text(encoding="utf-8").encode()).hexdigest()
|
||||
record = text_by_hash.get(script_hash)
|
||||
if record:
|
||||
sample.metadata["text_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")}
|
||||
elif index < len(text_records):
|
||||
fallback = text_records[index]; sample.metadata["text_jsonl"] = {"line": fallback.get("_line"), "cad_file_id": fallback.get("cad_file_id"), "alignment": "fallback"}
|
||||
sample.diagnostics.append({"code": "alignment_fallback", "modality": "text_jsonl"})
|
||||
if index < len(image_records):
|
||||
record = image_records[index]
|
||||
sample.metadata["image_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")}
|
||||
samples.append(sample)
|
||||
return samples
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Diagnostic:
|
||||
code: str
|
||||
message: str
|
||||
severity: str = "error"
|
||||
feature_id: str | None = None
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Token:
|
||||
kind: str
|
||||
value: str
|
||||
line: int
|
||||
column: int
|
||||
|
||||
|
||||
_RE = re.compile(r"(?P<ws>\s+)|(?P<comment>//[^\n]*|/\*.*?\*/)|(?P<string>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|(?P<number>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)|(?P<ident>[A-Za-z_][A-Za-z0-9_.]*)|(?P<op>==|!=|<=|>=|=>|[{}\[\]():,;=+*/-])", re.S)
|
||||
|
||||
|
||||
def lex(source: str) -> list[Token]:
|
||||
out: list[Token] = []
|
||||
pos = 0; line = 1; column = 1
|
||||
while pos < len(source):
|
||||
match = _RE.match(source, pos)
|
||||
if not match:
|
||||
raise SyntaxError(f"unexpected character at {line}:{column}: {source[pos]!r}")
|
||||
kind, value = match.lastgroup or "", match.group(0)
|
||||
if kind not in {"ws", "comment"}:
|
||||
out.append(Token(kind, value, line, column))
|
||||
nl = value.count("\n")
|
||||
if nl: line, column = line + nl, len(value.rsplit("\n", 1)[-1]) + 1
|
||||
else: column += len(value)
|
||||
pos = match.end()
|
||||
out.append(Token("eof", "", line, column))
|
||||
return out
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from .featurescript_lexer import Token, lex
|
||||
from .ir import Call, FeatureIR, ModelIR, SketchIR
|
||||
|
||||
|
||||
def _string(value: Any) -> Any:
|
||||
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
|
||||
try:
|
||||
return bytes(value[1:-1], "utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, source: str):
|
||||
self.source, self.tokens, self.i = source, lex(source), 0
|
||||
|
||||
def peek(self) -> Token: return self.tokens[self.i]
|
||||
def pop(self) -> Token:
|
||||
token = self.tokens[self.i]; self.i += 1; return token
|
||||
def accept(self, value: str) -> bool:
|
||||
if self.peek().value == value: self.i += 1; return True
|
||||
return False
|
||||
|
||||
def primary(self) -> Any:
|
||||
token = self.pop()
|
||||
if token.value == "(":
|
||||
value = self.expression(); self.accept(")"); return value
|
||||
if token.kind == "string": return _string(token.value)
|
||||
if token.kind == "number": return float(token.value)
|
||||
if token.kind == "ident":
|
||||
if self.accept("("):
|
||||
args = []
|
||||
while self.peek().kind != "eof" and self.peek().value != ")":
|
||||
args.append(self.expression());
|
||||
if not self.accept(","): break
|
||||
self.accept(")")
|
||||
return Call(token.value, args, token.line)
|
||||
return token.value
|
||||
if token.value == "[":
|
||||
values = []
|
||||
while self.peek().kind != "eof" and self.peek().value != "]":
|
||||
values.append(self.expression());
|
||||
if not self.accept(","): break
|
||||
self.accept("]"); return values
|
||||
if token.value == "{":
|
||||
obj: dict[str, Any] = {}
|
||||
while self.peek().kind != "eof" and self.peek().value != "}":
|
||||
key = _string(self.pop().value); self.accept(":"); obj[str(key)] = self.expression()
|
||||
if not self.accept(","): break
|
||||
self.accept("}"); return obj
|
||||
return token.value
|
||||
|
||||
def expression(self, minimum: int = 0) -> Any:
|
||||
left = self.primary()
|
||||
precedence = {"+": 10, "-": 10, "*": 20, "/": 20}
|
||||
while self.peek().value in precedence and precedence[self.peek().value] >= minimum:
|
||||
op = self.pop(); right = self.expression(precedence[op.value] + 1)
|
||||
left = Call("__binary__", [left, op.value, right], op.line)
|
||||
return left
|
||||
|
||||
def statements(self) -> list[Call]:
|
||||
found: list[Call] = []; environment: dict[str, Any] = {}
|
||||
while self.peek().kind != "eof":
|
||||
if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=":
|
||||
name = self.pop().value; self.pop()
|
||||
try:
|
||||
environment[name] = self.expression()
|
||||
if isinstance(environment[name], Call) and environment[name].name == "newSketch":
|
||||
value = environment[name]
|
||||
value.args = [_resolve(arg, environment) for arg in value.args]
|
||||
found.append(value)
|
||||
except Exception: pass
|
||||
elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(":
|
||||
value = self.expression()
|
||||
if isinstance(value, Call):
|
||||
value.args = [_resolve(arg, environment) for arg in value.args]
|
||||
found.append(value)
|
||||
else: self.i += 1
|
||||
return found
|
||||
|
||||
|
||||
def _resolve(value: Any, environment: dict[str, Any], seen: set[str] | None = None) -> Any:
|
||||
seen = seen or set()
|
||||
if isinstance(value, str) and value in environment and value not in seen:
|
||||
return _resolve(environment[value], environment, seen | {value})
|
||||
if isinstance(value, Call): return Call(value.name, [_resolve(arg, environment, seen) for arg in value.args], value.line, value.raw)
|
||||
if isinstance(value, list): return [_resolve(arg, environment, seen) for arg in value]
|
||||
if isinstance(value, dict): return {key: _resolve(arg, environment, seen) for key, arg in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def symbolic_string(value: Any) -> str:
|
||||
if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "+":
|
||||
return symbolic_string(value.args[0]) + symbolic_string(value.args[2])
|
||||
if isinstance(value, str): return "" if value == "id" else value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _arg_map(call: Call) -> dict[str, Any]:
|
||||
# Object literals are parsed as dictionaries; FeatureScript operation calls
|
||||
# conventionally put the definition map in the final argument.
|
||||
return next((arg for arg in reversed(call.args) if isinstance(arg, dict)), {})
|
||||
|
||||
|
||||
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
|
||||
parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source)
|
||||
for call in calls:
|
||||
if call.name == "newSketch":
|
||||
definition = _arg_map(call)
|
||||
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"sketch_{len(model.sketches)}"
|
||||
sketch_ir = SketchIR(fid, definition.get("sketchPlane"), [])
|
||||
model.sketches.append(sketch_ir); model.steps.append(sketch_ir)
|
||||
elif call.name in {"skLineSegment", "skCircle", "skArc", "skEllipse", "skFitSpline", "skPoint"}:
|
||||
# Attach sketch entities to the most recently declared sketch.
|
||||
if model.sketches:
|
||||
args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}"
|
||||
model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name))
|
||||
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies"}:
|
||||
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}"
|
||||
feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name)
|
||||
model.features.append(feature_ir); model.steps.append(feature_ir)
|
||||
return model
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Call:
|
||||
name: str
|
||||
args: list[Any] = field(default_factory=list)
|
||||
line: int = 0
|
||||
raw: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureIR:
|
||||
feature_id: str
|
||||
operation: str
|
||||
params: dict[str, Any]
|
||||
queries: list[Any] = field(default_factory=list)
|
||||
line_start: int = 0
|
||||
line_end: int = 0
|
||||
raw_source: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SketchIR:
|
||||
feature_id: str
|
||||
workplane: Any
|
||||
entities: list[FeatureIR] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelIR:
|
||||
sample_id: str
|
||||
features: list[FeatureIR] = field(default_factory=list)
|
||||
sketches: list[SketchIR] = field(default_factory=list)
|
||||
steps: list[Any] = field(default_factory=list)
|
||||
raw_source: str = ""
|
||||
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from .featurescript_parser import symbolic_string
|
||||
from .ir import Call, FeatureIR, ModelIR, SketchIR
|
||||
from .query_parser import parse_query, walk_calls
|
||||
|
||||
|
||||
UNSUPPORTED = {"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
|
||||
PLANES = {
|
||||
"Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]},
|
||||
"Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]},
|
||||
"Right": {"origin_mm": [0., 0., 0.], "x_dir": [0., 1., 0.], "normal": [1., 0., 0.]},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoweringResult:
|
||||
cdsl: dict[str, Any] | None
|
||||
status: str
|
||||
diagnostics: list[dict[str, Any]]
|
||||
history: list[dict[str, Any]]
|
||||
|
||||
|
||||
def plain(value: Any) -> Any:
|
||||
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
|
||||
if isinstance(value, list): return [plain(item) for item in value]
|
||||
if isinstance(value, dict): return {key: plain(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
return value is True or (isinstance(value, str) and value.lower() == "true")
|
||||
|
||||
|
||||
def _number(value: Any, units: bool = False) -> float:
|
||||
if isinstance(value, (float, int)): return float(value)
|
||||
if isinstance(value, str):
|
||||
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8}
|
||||
if value in constants: return constants[value]
|
||||
return float(value)
|
||||
if isinstance(value, Call) and value.name == "__binary__":
|
||||
left, op, right = value.args; a, b = _number(left, units), _number(right, units)
|
||||
return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)]
|
||||
raise ValueError(f"not a constant number: {plain(value)!r}")
|
||||
|
||||
|
||||
def _point(value: Any) -> list[float]:
|
||||
if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*":
|
||||
scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point]
|
||||
if isinstance(value, Call) and value.name in {"v", "vector"} and len(value.args) >= 2:
|
||||
return [_number(value.args[0]), _number(value.args[1])]
|
||||
if isinstance(value, list) and len(value) >= 2: return [_number(value[0]), _number(value[1])]
|
||||
raise ValueError(f"not a 2D point: {plain(value)!r}")
|
||||
|
||||
|
||||
def _cross(a: list[float], b: list[float]) -> list[float]:
|
||||
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
|
||||
|
||||
|
||||
def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"])
|
||||
|
||||
|
||||
def _global(plane: dict[str, Any], point: list[float]) -> list[float]:
|
||||
y = _y_dir(plane); return [plane["origin_mm"][i] + plane["x_dir"][i]*point[0] + y[i]*point[1] for i in range(3)]
|
||||
|
||||
|
||||
def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
|
||||
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
|
||||
|
||||
|
||||
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
for call in walk_calls(value):
|
||||
if call.name in {"makeId", "qCreatedBy"}:
|
||||
text = " ".join(symbolic_string(arg) for arg in call.args)
|
||||
for name, plane in PLANES.items():
|
||||
if f"{name}.planeOp" in text: return dict(plane)
|
||||
query = parse_query(value)
|
||||
frame = feature_frames.get(query.owner_feature or "")
|
||||
if frame:
|
||||
return dict(frame["start" if query.is_start is not False else "end"])
|
||||
raise ValueError("unsupported or unresolved sketch workplane")
|
||||
|
||||
|
||||
def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]:
|
||||
ax, ay = start; bx, by = mid; cx, cy = end
|
||||
d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
|
||||
if abs(d) < 1e-9: raise ValueError("collinear arc points")
|
||||
ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d
|
||||
uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d
|
||||
cross = (mid[0]-start[0])*(end[1]-mid[1])-(mid[1]-start[1])*(end[0]-mid[0])
|
||||
return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": cross < 0}
|
||||
|
||||
|
||||
def _endpoint(segment: dict[str, Any], end: bool = False) -> tuple[int, int]:
|
||||
point = segment["end" if end else "start"]; return round(point[0]*1e5), round(point[1]*1e5)
|
||||
|
||||
|
||||
def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
circles = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] == "circle"]
|
||||
edges = [item for item in segments if item["type"] != "circle"]; unused = set(range(len(edges))); contours = []; construction = []
|
||||
while unused:
|
||||
idx = unused.pop(); contour = [edges[idx]]; first = _endpoint(contour[0]); tail = _endpoint(contour[-1], True)
|
||||
while tail != first:
|
||||
match = next((j for j in unused if _endpoint(edges[j]) == tail or _endpoint(edges[j], True) == tail), None)
|
||||
if match is None:
|
||||
construction.extend(contour); break
|
||||
unused.remove(match); item = dict(edges[match])
|
||||
if _endpoint(item, True) == tail:
|
||||
item["start"], item["end"] = item["end"], item["start"]
|
||||
if item["type"] == "arc": item["clockwise"] = not item["clockwise"]
|
||||
contour.append(item); tail = _endpoint(item, True)
|
||||
if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour})
|
||||
return contours + circles, construction
|
||||
|
||||
|
||||
def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
||||
segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = []
|
||||
for entity in sketch.entities:
|
||||
p = entity.params
|
||||
if entity.operation == "skPoint": entities[entity.feature_id] = {"type": "point", "point": _point(p["position"])}; continue
|
||||
if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])}
|
||||
elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)}
|
||||
elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"]))
|
||||
else: unsupported.append(entity.operation); continue
|
||||
(explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item
|
||||
if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported))))
|
||||
if not segments:
|
||||
profile: dict[str, Any] = {"type": "analytic_contours", "contours": []}
|
||||
if explicit_construction: profile["construction"] = explicit_construction
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
|
||||
if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction:
|
||||
profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]}
|
||||
else:
|
||||
contours, construction = _contours(segments); construction.extend(explicit_construction)
|
||||
if not contours:
|
||||
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
|
||||
profile = {"type": "analytic_contours", "contours": contours}
|
||||
if construction: profile["construction"] = construction
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile}, entities
|
||||
|
||||
|
||||
def _queries(value: Any) -> list[Any]:
|
||||
if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return value.args[0]
|
||||
return [value]
|
||||
|
||||
|
||||
def _source_refs(value: Any) -> list[tuple[str, str]]:
|
||||
refs = []
|
||||
for call in walk_calls(value):
|
||||
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
|
||||
refs.append((symbolic_string(call.args[0]).split(".", 1)[0], str(call.args[2])))
|
||||
return refs
|
||||
|
||||
|
||||
def _source_sketch(params: dict[str, Any]) -> str | None:
|
||||
for key in ("entities", "sheetProfilesArray"):
|
||||
if key in params:
|
||||
query = parse_query(params[key])
|
||||
if query.source_sketch: return query.source_sketch
|
||||
return None
|
||||
|
||||
|
||||
def _profile_executable(sketch: dict[str, Any]) -> bool:
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "circle": return True
|
||||
if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3
|
||||
return bool(profile.get("contours"))
|
||||
|
||||
|
||||
def _default_plane(value: Any) -> dict[str, Any] | None:
|
||||
for call in walk_calls(value):
|
||||
text = " ".join(symbolic_string(arg) for arg in call.args)
|
||||
for name, plane in PLANES.items():
|
||||
if f"{name}.planeOp" in text: return dict(plane)
|
||||
return None
|
||||
|
||||
|
||||
def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
diagnostics: list[dict[str, Any]] = []; history = []
|
||||
sketches: list[dict[str, Any]] = []; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
feature_frames: dict[str, dict[str, Any]] = {}
|
||||
features: list[dict[str, Any]] = []; complete = True; previous: list[str] = []
|
||||
for step in model.steps:
|
||||
if isinstance(step, SketchIR):
|
||||
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
|
||||
try:
|
||||
plane = _plane_from_query(step.workplane, feature_frames)
|
||||
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
|
||||
continue
|
||||
item = step
|
||||
history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source})
|
||||
if item.operation in UNSUPPORTED:
|
||||
diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue
|
||||
try:
|
||||
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
|
||||
if item.operation == "cPlane":
|
||||
base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"}
|
||||
feature_frames[item.feature_id] = {"start": plane, "end": plane}
|
||||
elif item.operation == "extrude":
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile")
|
||||
depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection"))
|
||||
atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind"
|
||||
params = {"distance_mm": depth, "reverse": reverse}
|
||||
if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True)
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"}
|
||||
plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)}
|
||||
elif item.operation == "revolve":
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile")
|
||||
axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "")
|
||||
if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved")
|
||||
plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"])
|
||||
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
|
||||
operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add"
|
||||
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
|
||||
elif item.operation in {"fillet", "chamfer"}:
|
||||
key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = []
|
||||
for index, query_value in enumerate(_queries(p.get("entities"))):
|
||||
query = parse_query(query_value); owner = query.owner_feature
|
||||
if not owner: raise ValueError("selector owner is unresolved")
|
||||
refs = _source_refs(query_value)
|
||||
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
|
||||
geometry: dict[str, Any] = {}
|
||||
frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"]
|
||||
if source_entity and cap:
|
||||
if query.topology_type == "SWEPT_EDGE" and frame:
|
||||
local_point = source_entity.get("point") if source_entity["type"] == "point" else None
|
||||
if len(refs) >= 2:
|
||||
left = (entity_by_sketch.get(refs[0][0]) or {}).get(refs[0][1]); right = (entity_by_sketch.get(refs[1][0]) or {}).get(refs[1][1])
|
||||
if left and right and left.get("type") == right.get("type") == "line":
|
||||
local_point = next((a for a in (left["start"], left["end"]) for b in (right["start"], right["end"]) if math.dist(a, b) <= 1e-5), None)
|
||||
if local_point is None: raise ValueError("swept edge source intersection is unresolved")
|
||||
start, end = _global(frame["start"], local_point), _global(frame["end"], local_point)
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
elif source_entity["type"] == "circle":
|
||||
# OCC/build123d commonly splits a closed circular edge into four
|
||||
# quarter-circle records. Bind all four deterministic arc centres.
|
||||
radius = source_entity["radius_mm"]; center = source_entity["center"]
|
||||
for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
|
||||
local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)]
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}})
|
||||
continue
|
||||
elif source_entity["type"] == "line":
|
||||
start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"])
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
if not geometry: raise ValueError("selector geometry is unresolved")
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
|
||||
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
|
||||
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
|
||||
elif item.operation == "hole":
|
||||
locations = _queries(p.get("locations")); positions = []; host_plane = None
|
||||
for location in locations:
|
||||
query = parse_query(location); source = query.source_sketch
|
||||
entity = (entity_by_sketch.get(source or "") or {}).get(query.source_entity or "")
|
||||
if not source or source not in sketch_by_source or not entity or entity.get("type") != "point": raise ValueError("hole location is unresolved")
|
||||
positions.append({"mm": [entity["point"][0], entity["point"][1], 0.0]}); host_plane = sketch_by_source[source]["workplane"]
|
||||
if not positions or host_plane is None: raise ValueError("hole has no resolved locations")
|
||||
frame = {**host_plane, "y_dir": _y_dir(host_plane)}
|
||||
if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]}
|
||||
style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper()
|
||||
condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end else "blind"
|
||||
depth_value = p.get("holeDepth") or p.get("tappedDepth")
|
||||
if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved")
|
||||
depth = _number(depth_value, True) if depth_value is not None else 1.0
|
||||
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}}
|
||||
if "COUNTERSINK" in style.upper():
|
||||
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))}
|
||||
if "COUNTERBORE" in style.upper():
|
||||
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)}
|
||||
if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
|
||||
elif item.operation == "mirror":
|
||||
owners = []
|
||||
for call in walk_calls(p.get("entities")):
|
||||
if call.name == "makeQuery" and call.args:
|
||||
owner = symbolic_string(call.args[0]);
|
||||
if "F" in owner:
|
||||
source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0]
|
||||
if source_id in previous and source_id not in owners: owners.append(source_id)
|
||||
if not owners: raise ValueError("mirror source features are unresolved")
|
||||
plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None
|
||||
if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features):
|
||||
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0}
|
||||
else:
|
||||
plane = _default_plane(plane_query)
|
||||
if plane is None: raise ValueError("mirror plane is not a default or reference plane")
|
||||
plane_owner = f"{fid}_plane"
|
||||
features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"})
|
||||
previous.append(plane_owner)
|
||||
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"}
|
||||
else:
|
||||
raise ValueError(f"operation mapping not implemented: {item.operation}")
|
||||
features.append(feature); previous.append(fid)
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
|
||||
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
|
||||
cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id,
|
||||
"meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})},
|
||||
"geometry": {"sketches": sketches}, "features": features}
|
||||
return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history)
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib, multiprocessing, random
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .compare import compare_steps
|
||||
from .dataset import Sample, scan_dataset
|
||||
from .featurescript_parser import parse_featurescript
|
||||
from .lowering import lower_model
|
||||
from .rebuild import rebuild_candidate
|
||||
from .reports import generate_reports, read_json, write_json, write_manifest
|
||||
|
||||
PIPELINE_VERSION = "cadfs_to_cdsl.v1"
|
||||
|
||||
|
||||
def _fingerprint(sample: Sample) -> str:
|
||||
value = PIPELINE_VERSION + "|" + "|".join(f"{key}:{value}" for key, value in sorted(sample.hashes.items()))
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _sample_dir(output: Path, sample_id: str) -> Path: return output / "samples" / sample_id
|
||||
|
||||
|
||||
def scan(input_root: Path, output: Path) -> list[dict[str, Any]]:
|
||||
records = [sample.as_dict() for sample in scan_dataset(input_root)]
|
||||
write_json(output / "dataset_index.json", {"schema": "cadfs_to_cdsl.dataset_index.v1", "input": str(input_root), "sample_count": len(records), "records": records})
|
||||
initial = [{"sample_id": item["sample_id"], "status": "scanned", "diagnostics": item["diagnostics"]} for item in records]
|
||||
write_manifest(output / "manifest.jsonl", initial)
|
||||
return records
|
||||
|
||||
|
||||
def load_samples(input_root: Path, output: Path) -> list[Sample]:
|
||||
index = output / "dataset_index.json"
|
||||
records = read_json(index)["records"] if index.exists() else scan(input_root, output)
|
||||
return [Sample(**{key: value for key, value in item.items() if key in {"sample_id", "files", "hashes", "metadata", "diagnostics"}}) for item in records]
|
||||
|
||||
|
||||
def select_samples(samples: list[Sample], *, sample_ids: list[str] | None = None, offset: int = 0, limit: int | None = None, seed: int | None = None) -> list[Sample]:
|
||||
if sample_ids:
|
||||
wanted = set(sample_ids); chosen = [sample for sample in samples if sample.sample_id in wanted]
|
||||
missing = wanted - {sample.sample_id for sample in chosen}
|
||||
if missing: raise ValueError("unknown sample ids: " + ", ".join(sorted(missing)))
|
||||
return chosen
|
||||
chosen = list(samples)
|
||||
if seed is not None and limit is not None:
|
||||
chosen = random.Random(seed).sample(chosen, min(limit, len(chosen))); chosen.sort(key=lambda item: item.sample_id); return chosen
|
||||
return chosen[offset:None if limit is None else offset + limit]
|
||||
|
||||
|
||||
def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[str, Any]:
|
||||
directory = _sample_dir(output, sample.sample_id); directory.mkdir(parents=True, exist_ok=True)
|
||||
fingerprint = _fingerprint(sample); status_path = directory / "status.json"
|
||||
if not force and status_path.exists():
|
||||
cached = read_json(status_path)
|
||||
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
|
||||
return cached
|
||||
diagnostics = list(sample.diagnostics)
|
||||
try:
|
||||
feature_path = Path(sample.files["featurescript"])
|
||||
model = parse_featurescript(feature_path.read_text(encoding="utf-8"), sample.sample_id)
|
||||
provenance = {"source_featurescript": str(feature_path), **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, "jsonl": sample.metadata}
|
||||
result = lower_model(model, provenance); diagnostics.extend(result.diagnostics)
|
||||
write_json(directory / "history.json", result.history); write_json(directory / "diagnostics.json", diagnostics)
|
||||
if result.cdsl is not None:
|
||||
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
|
||||
semantic = validate_semantic_cdsl(result.cdsl); write_json(directory / "candidate.cdsl.json", result.cdsl)
|
||||
else: semantic = None
|
||||
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": result.status, "conversion_status": result.status, "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics)}
|
||||
except Exception as exc:
|
||||
missing = isinstance(exc, FileNotFoundError)
|
||||
diagnostics.append({"code": "source_missing" if missing else "parse_or_lowering_failed", "message": str(exc), "type": type(exc).__name__})
|
||||
write_json(directory / "diagnostics.json", diagnostics)
|
||||
final_status = "source_missing" if missing else "parse_failed"
|
||||
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": final_status, "conversion_status": final_status, "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics)}
|
||||
write_json(status_path, status); return status
|
||||
|
||||
|
||||
def _rebuild_worker(candidate: str, step: str, result: str) -> None:
|
||||
from .rebuild import rebuild_candidate
|
||||
write_json(Path(result), rebuild_candidate(read_json(Path(candidate)), Path(step)))
|
||||
|
||||
|
||||
def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
|
||||
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
|
||||
|
||||
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool:
|
||||
result_path.unlink(missing_ok=True)
|
||||
context = multiprocessing.get_context("spawn")
|
||||
process = context.Process(target=target, args=args)
|
||||
process.start(); process.join(timeout_seconds)
|
||||
if process.is_alive():
|
||||
process.terminate(); process.join(5)
|
||||
if process.is_alive(): process.kill(); process.join()
|
||||
return False
|
||||
return process.exitcode == 0 and result_path.exists()
|
||||
|
||||
|
||||
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
|
||||
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
|
||||
status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force)
|
||||
if status.get("conversion_status") != "converted_complete": return status
|
||||
rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step"
|
||||
if not force and rebuild_path.exists() and status.get("rebuild_status"):
|
||||
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
|
||||
worker_result = directory / "rebuild.worker.json"
|
||||
completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True)
|
||||
else:
|
||||
step_path.unlink(missing_ok=True)
|
||||
result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}}
|
||||
write_json(rebuild_path, result)
|
||||
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
|
||||
write_json(status_path, status); return status
|
||||
|
||||
|
||||
def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 60.0) -> dict[str, Any]:
|
||||
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
|
||||
status = read_json(status_path) if status_path.exists() else rebuild_one(sample, output, force=force)
|
||||
if status.get("rebuild_status") != "rebuilt": return status
|
||||
comparison_path = directory / "comparison.json"
|
||||
if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status
|
||||
if force or not comparison_path.exists():
|
||||
worker_result = directory / "comparison.worker.json"
|
||||
completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
|
||||
else:
|
||||
status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status
|
||||
else: comparison = read_json(comparison_path)
|
||||
status["comparison_decision"] = comparison["decision"]
|
||||
accepted = comparison[compare_mode]["passed"]
|
||||
status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected"
|
||||
write_json(status_path, status); return status
|
||||
|
||||
|
||||
def run_stage(stage: str, samples: list[Sample], output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 30.0, workers: int = 1) -> list[dict[str, Any]]:
|
||||
def process(sample: Sample) -> dict[str, Any]:
|
||||
if stage == "convert": record = convert_one(sample, output, force=force)
|
||||
elif stage == "rebuild": record = rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds)
|
||||
elif stage == "compare": record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds))
|
||||
elif stage == "pipeline":
|
||||
convert_one(sample, output, force=force); rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds); record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds))
|
||||
else: raise ValueError(f"unknown stage {stage!r}")
|
||||
return record
|
||||
if workers == 1: records = [process(sample) for sample in samples]
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs") as executor:
|
||||
records = list(executor.map(process, samples))
|
||||
manifest_path = output / "manifest.jsonl"
|
||||
existing = {}
|
||||
if manifest_path.exists():
|
||||
import json
|
||||
existing = {item["sample_id"]: item for line in manifest_path.read_text(encoding="utf-8").splitlines() if line.strip() for item in [json.loads(line)]}
|
||||
existing.update({item["sample_id"]: item for item in records})
|
||||
merged = [existing[key] for key in sorted(existing)]
|
||||
write_manifest(manifest_path, merged); generate_reports(output, merged)
|
||||
return records
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
from .featurescript_parser import symbolic_string
|
||||
from .ir import Call
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryInfo:
|
||||
kind: str | None = None
|
||||
owner_feature: str | None = None
|
||||
topology_type: str | None = None
|
||||
source_sketch: str | None = None
|
||||
source_entity: str | None = None
|
||||
is_start: bool | None = None
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]: return asdict(self)
|
||||
|
||||
|
||||
def walk_calls(value: Any):
|
||||
if isinstance(value, Call):
|
||||
yield value
|
||||
for arg in value.args: yield from walk_calls(arg)
|
||||
elif isinstance(value, list):
|
||||
for item in value: yield from walk_calls(item)
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values(): yield from walk_calls(item)
|
||||
|
||||
|
||||
def parse_query(value: Any) -> QueryInfo:
|
||||
info = QueryInfo()
|
||||
for call in walk_calls(value):
|
||||
info.calls.append(call.name)
|
||||
if call.name in {"makeQuery", "qCreatedBy"} and call.args:
|
||||
owner = symbolic_string(call.args[0])
|
||||
if "F" in owner:
|
||||
tail = owner[owner.find("F"):].split(".", 1)[0]
|
||||
info.owner_feature = tail
|
||||
if call.name == "makeQuery" and len(call.args) > 2:
|
||||
info.topology_type = str(call.args[1]); info.kind = str(call.args[2]).lower()
|
||||
definition = next((arg for arg in call.args if isinstance(arg, dict)), {})
|
||||
if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true"
|
||||
elif "isStart" in definition: info.is_start = bool(definition["isStart"])
|
||||
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
|
||||
sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0]
|
||||
info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2])
|
||||
if call.name == "qSketchRegion" and call.args:
|
||||
info.source_sketch = symbolic_string(call.args[0]); info.kind = "face"
|
||||
return info
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
|
||||
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
|
||||
if not analysis.runtime_eligible:
|
||||
return {"status": "runtime_ineligible", "analysis": analysis_dict}
|
||||
try:
|
||||
result = rebuild_cdsl(cdsl, output, strict=True)
|
||||
return {"status": "rebuilt", "analysis": analysis_dict, "result": result}
|
||||
except Exception as exc:
|
||||
detail = {"type": type(exc).__name__, "message": str(exc)}
|
||||
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
|
||||
return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv, json
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_manifest(path: Path, records: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("".join(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n" for item in records), encoding="utf-8")
|
||||
|
||||
|
||||
def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
statuses = Counter(str(item.get("status") or "unknown") for item in records)
|
||||
operations: Counter[str] = Counter(); reasons: Counter[str] = Counter(); gaps: dict[str, list[str]] = defaultdict(list)
|
||||
rows = []
|
||||
for item in records:
|
||||
sample_dir = output / "samples" / str(item["sample_id"])
|
||||
diagnostics_path = sample_dir / "diagnostics.json"
|
||||
if diagnostics_path.exists():
|
||||
for diagnostic in read_json(diagnostics_path):
|
||||
reasons[str(diagnostic.get("code") or "unknown")] += 1
|
||||
if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"]))
|
||||
history_path = sample_dir / "history.json"
|
||||
if history_path.exists():
|
||||
for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1
|
||||
comparison_path = sample_dir / "comparison.json"
|
||||
if comparison_path.exists():
|
||||
comparison = read_json(comparison_path); metrics = comparison["raw"]["metrics"]
|
||||
rows.append({"sample_id": item["sample_id"], "decision": comparison["decision"], "bbox_max_delta_mm": metrics["bbox_max_delta_mm"], "volume_relative_error": metrics["volume_relative_error"], "surface_area_relative_error": metrics["surface_area_relative_error"]})
|
||||
summary = {"schema": "cadfs_to_cdsl.summary.v1", "total_models": len(records), "statuses": dict(statuses), "operation_counts": dict(operations), "failure_reasons": dict(reasons), "capability_gap_counts": {key: len(set(value)) for key, value in gaps.items()}}
|
||||
write_json(output / "summary.json", summary)
|
||||
gap_payload = {key: {"sample_count": len(set(ids)), "sample_ids": sorted(set(ids))} for key, ids in sorted(gaps.items())}; write_json(output / "capability_gaps.json", gap_payload)
|
||||
lines = ["# Unsupported CADFS capabilities", ""]
|
||||
for name, value in gap_payload.items(): lines.extend([f"## {name}", "", f"Affected models: {value['sample_count']}", "", "Sample IDs: " + ", ".join(value["sample_ids"]), ""])
|
||||
(output / "unsupported_capabilities.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows)
|
||||
return summary
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
|
||||
scores: list[float] = []
|
||||
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"):
|
||||
if key in expected:
|
||||
left, right = expected[key], actual.get(key)
|
||||
if not isinstance(right, (list, tuple)) or len(left) != len(right): return None
|
||||
delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right)))
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
for key in ("radius_mm", "plane_offset_mm"):
|
||||
if key in expected:
|
||||
try: delta = abs(float(expected[key]) - float(actual[key]))
|
||||
except Exception: return None
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
return sum(scores) / len(scores) if scores else 0.0
|
||||
|
||||
|
||||
def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], records: list[dict[str, Any]], *, minimum_score: float = 0.8) -> dict[str, Any]:
|
||||
candidates = []
|
||||
for record in records:
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
||||
if record.get("kind") != kind or owner_feature_id not in owners: continue
|
||||
score = _score(geometry, record.get("geometry") or {})
|
||||
if score is not None and score >= minimum_score: candidates.append((score, record))
|
||||
candidates.sort(key=lambda item: (-item[0], str(item[1].get("record_id"))))
|
||||
if not candidates: raise ValueError("selector_not_found")
|
||||
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
|
||||
score, record = candidates[0]
|
||||
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile, unittest
|
||||
from pathlib import Path
|
||||
from cadfs_to_cdsl.compare import compare_steps
|
||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||
from cadfs_to_cdsl.lowering import lower_model
|
||||
|
||||
|
||||
class IntegrationTests(unittest.TestCase):
|
||||
def test_known_rp_roundtrip_00000173(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
|
||||
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
|
||||
cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True)
|
||||
comparison = compare_steps(gold, rebuilt)
|
||||
self.assertFalse(comparison["strict"]["passed"])
|
||||
self.assertTrue(comparison["rp"]["passed"])
|
||||
self.assertEqual(comparison["decision"], "approximate_pass")
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json, tempfile, unittest
|
||||
from pathlib import Path
|
||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||
from cadfs_to_cdsl.lowering import lower_model
|
||||
from cadfs_to_cdsl.pipeline import convert_one
|
||||
from cadfs_to_cdsl.dataset import Sample
|
||||
from cadfs_to_cdsl.dataset import scan_dataset
|
||||
from cadfs_to_cdsl.tests.test_parser import SOURCE
|
||||
|
||||
|
||||
class LoweringTests(unittest.TestCase):
|
||||
def test_missing_dataset_fails_explicitly(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing")
|
||||
|
||||
def test_circle_extrude_is_valid_cdsl_11(self):
|
||||
result = lower_model(parse_featurescript(SOURCE, "00000173"), {})
|
||||
self.assertEqual(result.status, "converted_complete")
|
||||
self.assertEqual(result.cdsl["schema_version"], "1.1.0")
|
||||
self.assertEqual(result.cdsl["geometry"]["sketches"][0]["profile"]["radius_mm"], 9.53)
|
||||
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
|
||||
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
|
||||
|
||||
def test_unsupported_operation_is_audited_not_invented(self):
|
||||
source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",')
|
||||
result = lower_model(parse_featurescript(source, "00000173"), {})
|
||||
self.assertEqual(result.status, "deferred_no_executable_feature")
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
|
||||
|
||||
def test_conversion_writes_status_and_sidecars(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
|
||||
sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"})
|
||||
status = convert_one(sample, root / "out", force=True)
|
||||
directory = root / "out/samples/00000173"
|
||||
self.assertEqual(status["conversion_status"], "converted_complete")
|
||||
for name in ("candidate.cdsl.json", "history.json", "diagnostics.json", "status.json"):
|
||||
self.assertTrue((directory / name).exists(), name)
|
||||
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from cadfs_to_cdsl.featurescript_lexer import lex
|
||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||
from cadfs_to_cdsl.ir import Call
|
||||
from cadfs_to_cdsl.query_parser import parse_query
|
||||
from cadfs_to_cdsl.units import length_mm
|
||||
|
||||
|
||||
SOURCE = r'''
|
||||
FeatureScript 1511;
|
||||
export const f = defineFeature(function(context, id, definition) {
|
||||
{ var Q0; Q0=qCreatedBy(makeId("Top.planeOp"), FACE);
|
||||
var sketch = newSketch(context, id + "F0", {"sketchPlane":qUnion([Q0])});
|
||||
skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch); }
|
||||
{ var Q0; Q0=qSketchRegion(id + "F0", true);
|
||||
extrude(context, id + "F1", {"entities":qUnion([Q0]), "depth":120 * mm}); }
|
||||
});
|
||||
'''
|
||||
|
||||
|
||||
class ParserTests(unittest.TestCase):
|
||||
def test_lexer_ignores_comments_and_preserves_lines(self):
|
||||
tokens = lex('// a\nfoo(/*b*/"x")')
|
||||
self.assertEqual([token.value for token in tokens[:-1]], ["foo", "(", '"x"', ")"])
|
||||
self.assertEqual(tokens[0].line, 2)
|
||||
|
||||
def test_nested_feature_script(self):
|
||||
model = parse_featurescript(SOURCE, "00000173")
|
||||
self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1"])
|
||||
self.assertEqual(model.sketches[0].entities[0].operation, "skCircle")
|
||||
self.assertIsInstance(model.features[0].params["entities"], Call)
|
||||
|
||||
def test_query_parser(self):
|
||||
query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}])
|
||||
value = parse_query(query)
|
||||
self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0"))
|
||||
self.assertFalse(value.is_start)
|
||||
|
||||
def test_safe_units(self):
|
||||
self.assertEqual(length_mm("2 * inch"), 50.8)
|
||||
self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7)
|
||||
with self.assertRaises(ValueError): length_mm("external.value * mm")
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0,
|
||||
"meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4,
|
||||
"ft": 304.8, "foot": 304.8, "feet": 304.8}
|
||||
_NUM_UNIT = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?([A-Za-z]+)\s*$")
|
||||
|
||||
|
||||
def _eval(node: ast.AST, names: dict[str, float]) -> float:
|
||||
if isinstance(node, ast.Expression): return _eval(node.body, names)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value)
|
||||
if isinstance(node, ast.Name) and node.id in names: return names[node.id]
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
||||
value = _eval(node.operand, names); return value if isinstance(node.op, ast.UAdd) else -value
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
||||
left, right = _eval(node.left, names), _eval(node.right, names)
|
||||
return {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}[type(node.op)](left, right)
|
||||
raise ValueError("expression is not a safe constant")
|
||||
|
||||
|
||||
def length_mm(expression: Any, names: dict[str, float] | None = None) -> float:
|
||||
if not isinstance(expression, str): raise ValueError("missing length expression")
|
||||
text = expression.strip().replace("millimeter", "mm").replace("inch", "in")
|
||||
match = _NUM_UNIT.match(text)
|
||||
if match:
|
||||
unit = match.group(2).lower()
|
||||
if unit not in SCALE: raise ValueError(f"unsupported unit {unit!r}")
|
||||
return float(match.group(1)) * SCALE[unit]
|
||||
env = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "inch": 25.4, "in": 25.4, **(names or {})}
|
||||
try:
|
||||
return _eval(ast.parse(text, mode="eval"), env)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"unsupported length expression {expression!r}") from exc
|
||||
Reference in New Issue
Block a user