feat(cadfs): expand selector provenance coverage

This commit is contained in:
2026-09-14 11:00:14 +08:00
parent 994d06aaea
commit de21ee8a5b
45 changed files with 19837 additions and 842 deletions
+138 -5
View File
@@ -489,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo
return inside
def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
"""Return whether a sampled contour point lies on one sampled boundary.
Region nesting is topology, not a ray-casting tie-break. A contour that
shares a vertex or an edge with another contour cannot be a hole of it.
The ordinary parity test intentionally leaves that boundary case
unspecified, so keep it out of containment classification explicitly.
"""
if len(loop) < 2:
return False
px, py = point
previous = loop[-1]
for current in loop:
dx, dy = current[0] - previous[0], current[1] - previous[1]
length_squared = dx * dx + dy * dy
if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM:
if math.dist(point, previous) <= _TOLERANCE_MM:
return True
else:
projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared
if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM:
nearest = (previous[0] + projection * dx, previous[1] + projection * dy)
if math.dist(point, nearest) <= _TOLERANCE_MM:
return True
previous = current
return False
def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool:
"""Require every sampled inner boundary point to be strictly inside outer.
This conservative predicate rejects touching and intersecting contours
rather than manufacturing an invalid face with a self-identical or shared
hole. Curved contours are already sampled by ``_sample_loop`` before
this stage, so the decision uses the same region representation as the
existing parity classifier.
"""
return bool(inner) and all(
not _point_on_loop_boundary(point, outer) and _contains(point, outer)
for point in inner
)
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
loops: list[_Ctx] = []
entities: list[_Ctx] = []
@@ -516,6 +559,16 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
))
raw_edges.extend(_segment_edges(segment))
if raw_edges:
# A pure surface extrusion consumes its source wire directly.
# Do not invent the closing edge used by the solid-cut open
# profile contract: it would turn one requested ruled face into
# a different shell. This marker is emitted only by the CADFS
# ToolBodyType.SURFACE lowering contract.
if bool(contour.get("surface_wire")):
if not contour_open or contour.get("role") != "open":
raise ValueError(f"analytic_contours: surface wire {index} must be an open contour")
meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True))
continue
edges = _join(raw_edges, allow_open=contour_open)
contour_opened = False
if contour_open:
@@ -537,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
if not loops:
return entities, []
for loop in loops:
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer"
loop["role"] = "inner" if sum(
_strictly_contains_loop(other["points"], loop["points"])
for other in loops if other is not loop
) % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"]
regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers]
for inner in (loop for loop in loops if loop["role"] == "inner"):
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])]
containing = [
outer for outer in outers
if _strictly_contains_loop(outer["points"], inner["points"])
]
if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour")
if any(outer.get("open") for outer in containing):
@@ -552,6 +611,58 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, []
def _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx:
"""Use the established region classifier once per source profile."""
kind = profile.get("type")
if kind == "analytic_contours":
return deepcopy(profile)
if kind == "circle":
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [{
"type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]),
"radius_mm": profile.get("radius_mm"),
}]}],
}
if kind == "polygon":
vertices = profile.get("vertices") or []
if len(vertices) < 3:
raise ValueError("multi_source_regions: polygon needs at least three vertices")
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])}
for index in range(len(vertices))
]}],
}
raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}")
def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Resolve each qSketchRegion source independently before taking its union.
Flattening all contours would reclassify a nested region from a separate
sketch as a hole. FeatureScript qUnion selects each source region, so its
operands must retain independent outer/hole classification here.
"""
sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or []
if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources):
raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles")
entities: list[_Ctx] = []
regions: list[_Ctx] = []
for child in profiles:
if not isinstance(child, dict):
raise ValueError("multi_source_regions: child profile must be an object")
child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []}
child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta)
if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]:
raise ValueError("multi_source_regions: every child must resolve to closed regions")
entities.extend(child_entities)
regions.extend(child_meta["_regions"])
meta["_regions"] = regions
return entities, []
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
@@ -571,8 +682,23 @@ def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ct
if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2:
raise ValueError("planar_imprint: at least two source entities are required")
external_anchors = profile.get("external_anchors") or []
external_ids = {
str(entry.get("id") or "")
for entry in external_anchors
if isinstance(entry, dict)
}
single_external_fragment = (
len(source_entities) == 1
and bool(external_ids)
and all(
isinstance(selection, dict)
and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids
for selection in profile.get("selections") or ()
)
)
if len(source_entities) < 2 and not single_external_fragment:
raise ValueError("planar_imprint: at least two source entities or one external anchor are required")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
@@ -582,6 +708,7 @@ CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"multi_source_regions": _gen_multi_source_regions,
"planar_imprint": _gen_planar_imprint,
}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
@@ -589,6 +716,7 @@ SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
"multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"},
}
@@ -609,7 +737,7 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [],
"_imprint_entities": [], "_imprint_selections": [], "_surface_wires": [],
}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
@@ -627,6 +755,11 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
if meta["_surface_wires"]:
output["surface_wires_mm"] = [
_transform_contours(wire, workplane) if workplane else wire
for wire in meta["_surface_wires"]
]
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{