feat(cadfs): expand selector provenance coverage
This commit is contained in:
@@ -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"] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user