feat(cadfs): expand reconstruction capability coverage

This commit is contained in:
2026-09-09 10:11:46 +08:00
parent 5bd1011335
commit 2f89d7930c
21 changed files with 8373 additions and 418 deletions
+120 -7
View File
@@ -82,6 +82,12 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
normal = workplane.get("normal") or [0, 0, 1]
for edge in contours:
output = deepcopy(edge)
if edge["type"] == "circle":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0)
output["normal"] = list(normal)
transformed.append(output)
continue
output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1])
output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1])
if edge["type"] == "arc":
@@ -245,10 +251,19 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
return _ellipse_edges(segment)
if kind == "bspline":
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
converted = [_point(point) for point in points]
periodic = bool(segment.get("periodic"))
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if len(converted) == 2:
if periodic:
raise ValueError("analytic_contours: two-point bspline cannot be periodic")
if _distance(converted[0], converted[1]) <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline endpoints must be distinct")
if start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents")
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
@@ -271,6 +286,8 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: bspline parameters must be finite")
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
if len(converted) == 2 and parameters is None:
raise ValueError("analytic_contours: two-point bspline requires explicit parameters")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
@@ -280,8 +297,6 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
**({"parameters": parameters} if parameters is not None else {}),
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
}
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
if start_tangent is not None:
@@ -293,13 +308,68 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
"""Convert an IMPRINT source while retaining its FeatureScript edge identity.
Closed contour assembly intentionally divides circles into four arcs so
that its loops have explicit vertices. An IMPRINT source id, however,
denotes one logical FeatureScript edge. Splitting that circle before the
OCC arrangement loses the one-to-one source/history mapping and makes a
valid fragment appear to be an ambiguous multi-edge source.
"""
if segment.get("type") != "circle":
return _segment_edges(segment)
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("planar_imprint: circle radius_mm must be > 0")
output: _Ctx = {
"type": "circle",
"center_mm": [float(center[0]), float(center[1]), 0.0],
"radius_mm": radius,
}
if "clockwise" in segment:
output["clockwise"] = bool(segment["clockwise"])
return [output]
def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
spline_points = edge["points_mm"]
if len(spline_points) == 2:
# GeomAPI_Interpolate with exactly two endpoint derivatives is
# the cubic Hermite curve over the explicit parameter span.
# There are no intermediate interpolation points to sample, so
# use its analytical points rather than incorrectly treating
# an otherwise valid curved loop as zero-area.
parameters = edge.get("parameters") or []
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(parameters) != 2 or start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline sampling is unresolved")
parameter_span = float(parameters[1]) - float(parameters[0])
if parameter_span <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline parameter span is degenerate")
start_point, end_point = spline_points
for fraction in (0.25, 0.5, 0.75):
squared = fraction * fraction
cubed = squared * fraction
h00 = 2.0 * cubed - 3.0 * squared + 1.0
h10 = cubed - 2.0 * squared + fraction
h01 = -2.0 * cubed + 3.0 * squared
h11 = cubed - squared
points.append((
h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0])
+ h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]),
h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1])
+ h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]),
))
continue
points.extend((float(point[0]), float(point[1])) for point in spline_points[1:-1])
continue
if edge.get("type") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
@@ -439,7 +509,38 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, []
CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours}
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
Unlike ``analytic_contours``, these curves are intentionally not joined
into a guessed outer wire. FeatureScript's IMPRINT query identifies
regions in the arrangement of all source curves, including open curves
and split fragments, and the geometry adapter chooses those actual B-rep
regions after the split.
"""
source_entities: list[_Ctx] = []
for source in profile.get("source_entities") or []:
source_id = str(source.get("id") or "")
curve = source.get("curve") or {}
if not source_id:
raise ValueError("planar_imprint: source entity id is missing")
edges = _imprint_segment_edges(curve)
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")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"planar_imprint": _gen_planar_imprint,
}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
@@ -463,7 +564,10 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
generator = SHAPE_GENERATORS.get(profile.get("type"))
if generator is None:
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}
meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [],
}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")]
@@ -480,6 +584,15 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{
"id": entity["id"],
"edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"],
}
for entity in meta["_imprint_entities"]
]
output["imprint_selections"] = meta["_imprint_selections"]
return output