优化流程

This commit is contained in:
2026-09-02 13:51:35 +08:00
parent 97a03c290b
commit f3eb5dff54
71 changed files with 5290 additions and 515 deletions
+3
View File
@@ -0,0 +1,3 @@
output/
__pycache__/
*.pyc
+16
View File
@@ -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>/`.
+4
View File
@@ -0,0 +1,4 @@
"""CADFS FeatureScript to CDSL conversion and validation pipeline."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -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
+29
View File
@@ -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}
+89
View File
@@ -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
+16
View File
@@ -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)
+33
View File
@@ -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
+126
View File
@@ -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
+39
View File
@@ -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 = ""
+313
View File
@@ -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)
+158
View File
@@ -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
+51
View File
@@ -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
+19
View File
@@ -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}
+50
View File
@@ -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
+34
View File
@@ -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}
View File
+25
View File
@@ -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()
+45
View File
@@ -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()
+47
View File
@@ -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()
+38
View File
@@ -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