feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector - 对候选分支执行有界重建与严格 STEP 比较 - 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录 - 增加 selector 候选搜索、预算限制和记录生成的测试 - 保持生产 selector resolver 不受 Demo 逻辑影响 - 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
This commit is contained in:
@@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl
|
||||
from OCP.BOPAlgo import BOPAlgo_Splitter
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.LocOpe import LocOpe_DPrism
|
||||
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopoDS import TopoDS
|
||||
@@ -30,7 +31,7 @@ from .parametric_gears import build_gear_solid, build_rack_solid
|
||||
from .parametric_thread import build_thread_solid
|
||||
from .runtime_types import (
|
||||
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
|
||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
|
||||
canonical_plane_signature,
|
||||
)
|
||||
from .topology_export import (
|
||||
@@ -45,6 +46,74 @@ def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||
return Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def make_interpolated_bspline_edge(
|
||||
points: list[Vector3],
|
||||
*,
|
||||
start_tangent: Vector3 | None = None,
|
||||
end_tangent: Vector3 | None = None,
|
||||
periodic: bool = False,
|
||||
parameters: list[float] | None = None,
|
||||
) -> Edge:
|
||||
"""Build the exact non-scaling interpolator used by CDSL sketch edges.
|
||||
|
||||
FeatureScript ``skFitSpline`` lowering retains its centripetal parameters
|
||||
and endpoint derivatives. Keep this construction in one kernel helper so
|
||||
geometry execution and consumers that need a source-curve differential do
|
||||
not accidentally use a chord or a separately parameterized interpolator.
|
||||
Callers remain responsible for their own CDSL contract validation.
|
||||
"""
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("interpolated B-spline requires both endpoint tangents")
|
||||
return Edge.make_spline(
|
||||
[_vector(point) for point in points],
|
||||
tangents=(
|
||||
[_vector(start_tangent), _vector(end_tangent)]
|
||||
if start_tangent is not None else None
|
||||
),
|
||||
periodic=periodic,
|
||||
parameters=parameters,
|
||||
scale=False,
|
||||
)
|
||||
|
||||
|
||||
def interpolated_bspline_point_and_tangent(
|
||||
points: list[Vector3],
|
||||
*,
|
||||
start_tangent: Vector3,
|
||||
end_tangent: Vector3,
|
||||
parameters: list[float],
|
||||
interpolation_index: int,
|
||||
) -> tuple[Vector3, Vector3]:
|
||||
"""Evaluate a CDSL interpolation point with the runtime's OCC curve.
|
||||
|
||||
``Edge.tangent_at`` accepts normalized edge positions, which need not be
|
||||
the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at
|
||||
the source parameter and return the actual point as well, allowing the
|
||||
caller to prove that OCC still interpolated the named source vertex.
|
||||
"""
|
||||
if interpolation_index < 0 or interpolation_index >= len(points):
|
||||
raise ValueError("B-spline interpolation index is out of range")
|
||||
if len(parameters) != len(points):
|
||||
raise ValueError("B-spline interpolation parameters must match points")
|
||||
if not all(math.isfinite(float(value)) for value in parameters):
|
||||
raise ValueError("B-spline interpolation parameters must be finite")
|
||||
if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])):
|
||||
raise ValueError("B-spline interpolation parameters must be strictly increasing")
|
||||
edge = make_interpolated_bspline_edge(
|
||||
points,
|
||||
start_tangent=start_tangent,
|
||||
end_tangent=end_tangent,
|
||||
parameters=parameters,
|
||||
)
|
||||
point = gp_Pnt()
|
||||
tangent = gp_Vec()
|
||||
BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent)
|
||||
return (
|
||||
(float(point.X()), float(point.Y()), float(point.Z())),
|
||||
(float(tangent.X()), float(tangent.Y()), float(tangent.Z())),
|
||||
)
|
||||
|
||||
|
||||
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
|
||||
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
||||
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
||||
@@ -96,7 +165,7 @@ class Build123dGeometryAdapter:
|
||||
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
|
||||
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||||
built: list[Edge] = []
|
||||
for edge in edges:
|
||||
@@ -137,12 +206,12 @@ class Build123dGeometryAdapter:
|
||||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
||||
else:
|
||||
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
||||
built.append(Edge.make_spline(
|
||||
points,
|
||||
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
|
||||
built.append(make_interpolated_bspline_edge(
|
||||
[(point.X, point.Y, point.Z) for point in points],
|
||||
start_tangent=start_tangent,
|
||||
end_tangent=end_tangent,
|
||||
periodic=bool(edge.get("periodic")),
|
||||
parameters=parameter_values,
|
||||
scale=False,
|
||||
))
|
||||
continue
|
||||
if edge.get("type") == "ellipse":
|
||||
@@ -163,7 +232,203 @@ class Build123dGeometryAdapter:
|
||||
else:
|
||||
# 直线边:直接连接首尾。
|
||||
built.append(Edge.make_line(start, end))
|
||||
return Wire(built)
|
||||
return built
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
return Wire(Build123dGeometryAdapter._wire_edges(edges))
|
||||
|
||||
@staticmethod
|
||||
def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]:
|
||||
"""Construct one wire and retain only explicit one-to-one source edges."""
|
||||
built = Build123dGeometryAdapter._wire_edges(edges)
|
||||
return Wire(built), [
|
||||
(str(edge["source_entity_id"]), built[index])
|
||||
for index, edge in enumerate(edges)
|
||||
if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"]
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]:
|
||||
"""Build one direct wire and retain the builder's exact source edges.
|
||||
|
||||
``Wire(built)`` may repair shared vertices by replacing individual
|
||||
edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at
|
||||
insertion time. The caller must still prove those handles are members
|
||||
of the final face before registering a source anchor.
|
||||
"""
|
||||
wire_builder = BRepBuilderAPI_MakeWire()
|
||||
source_edges: list[tuple[str, Edge]] = []
|
||||
for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)):
|
||||
wire_builder.Add(edge.wrapped)
|
||||
if not wire_builder.IsDone():
|
||||
raise ValueError("analytic contour wire construction failed")
|
||||
source_entity_id = definition.get("source_entity_id")
|
||||
if isinstance(source_entity_id, str) and source_entity_id:
|
||||
source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge())))
|
||||
return wire_builder.Wire(), source_edges
|
||||
|
||||
def _face_from_direct_wires(
|
||||
self,
|
||||
outer: list[dict[str, Any]],
|
||||
holes: Iterable[list[dict[str, Any]]] = (),
|
||||
*,
|
||||
logical_circle_sources: dict[str, dict[str, Any]] | None = None,
|
||||
plane_spec: PlaneSpec | None = None,
|
||||
) -> tuple[Face, list[tuple[str, Edge]]]:
|
||||
"""Build one direct profile face with exact outer and hole wire handles.
|
||||
|
||||
Constructing a face and then calling ``Face.make_holes`` can replace
|
||||
linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace``
|
||||
instead gives the face builder every source wire, so a later ``IsSame``
|
||||
membership check can establish provenance without geometry matching.
|
||||
"""
|
||||
outer_wire, source_edges = self._direct_wire_with_source_edges(outer)
|
||||
face_builder = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||
for hole in holes:
|
||||
hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges(
|
||||
hole,
|
||||
logical_circle_sources=logical_circle_sources,
|
||||
plane_spec=plane_spec,
|
||||
)
|
||||
# OCC requires an inner wire to have the opposite orientation to
|
||||
# its outer boundary. ``Wire.Reversed`` returns a generic shape,
|
||||
# so cast it back to TopoDS_Wire for the face-builder API.
|
||||
face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed()))
|
||||
source_edges.extend(hole_source_edges)
|
||||
if not face_builder.IsDone():
|
||||
raise ValueError("analytic contour face construction failed")
|
||||
return Face.cast(face_builder.Face()), source_edges
|
||||
|
||||
def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]:
|
||||
"""Build a direct face with no inner wires for existing callers."""
|
||||
return self._face_from_direct_wires(edges)
|
||||
|
||||
def _direct_hole_wire_with_source_edges(
|
||||
self,
|
||||
edges: list[dict[str, Any]],
|
||||
*,
|
||||
logical_circle_sources: dict[str, dict[str, Any]] | None,
|
||||
plane_spec: PlaneSpec | None,
|
||||
) -> tuple[Any, list[tuple[str, Edge]]]:
|
||||
"""Restore one solver-split source circle only from explicit provenance.
|
||||
|
||||
``analytic_contours`` decomposes a circle into four arcs to classify
|
||||
regions. Those arcs are not source edges. The solver records one
|
||||
logical-circle source marker on every generated arc, so a complete
|
||||
marked loop may be rebuilt as one native circle wire. Any missing,
|
||||
mixed, or unregistered marker uses the ordinary direct wire path and
|
||||
therefore retains no invented source edge.
|
||||
"""
|
||||
logical_ids = {
|
||||
edge.get("logical_circle_source_entity_id")
|
||||
for edge in edges
|
||||
if isinstance(edge.get("logical_circle_source_entity_id"), str)
|
||||
and edge["logical_circle_source_entity_id"]
|
||||
}
|
||||
if (
|
||||
len(edges) == 4
|
||||
and len(logical_ids) == 1
|
||||
and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges)
|
||||
and logical_circle_sources is not None
|
||||
and plane_spec is not None
|
||||
):
|
||||
source_entity_id = next(iter(logical_ids))
|
||||
source = logical_circle_sources.get(source_entity_id)
|
||||
center = source.get("center") if isinstance(source, dict) else None
|
||||
radius = source.get("radius_mm") if isinstance(source, dict) else None
|
||||
if (
|
||||
isinstance(center, list)
|
||||
and len(center) >= 2
|
||||
and isinstance(radius, (int, float))
|
||||
and math.isfinite(float(radius))
|
||||
and float(radius) > 0
|
||||
):
|
||||
wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec)
|
||||
return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()]
|
||||
return self._direct_wire_with_source_edges(edges)
|
||||
|
||||
@staticmethod
|
||||
def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Index uniquely named, unsplit circle source entities from one profile."""
|
||||
sources: dict[str, dict[str, Any]] = {}
|
||||
duplicates: set[str] = set()
|
||||
for contour in profile.get("contours") or []:
|
||||
if not isinstance(contour, dict):
|
||||
continue
|
||||
for segment in contour.get("segments") or []:
|
||||
if not isinstance(segment, dict) or segment.get("type") != "circle":
|
||||
continue
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
if not isinstance(source_entity_id, str) or not source_entity_id:
|
||||
continue
|
||||
if source_entity_id in sources:
|
||||
duplicates.add(source_entity_id)
|
||||
else:
|
||||
sources[source_entity_id] = segment
|
||||
for source_entity_id in duplicates:
|
||||
sources.pop(source_entity_id, None)
|
||||
return sources
|
||||
|
||||
@staticmethod
|
||||
def _face_source_anchor_specs(
|
||||
face: Face,
|
||||
source_edges: list[tuple[str, Edge]],
|
||||
source_sketch_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Bind direct source labels to the exact face boundary subshapes.
|
||||
|
||||
The caller retains the wire edges created for this profile. A source
|
||||
label is emitted only when it has one exact final face edge; there is
|
||||
no geometry-based reconstruction when splitting, trimming, or wire
|
||||
construction changes that one-to-one relationship.
|
||||
"""
|
||||
if not isinstance(source_sketch_id, str) or not source_sketch_id:
|
||||
return []
|
||||
actual_edges = list(face.edges())
|
||||
mapped: list[tuple[str, Edge]] = []
|
||||
source_counts: dict[str, int] = {}
|
||||
for source_entity_id, _edge in source_edges:
|
||||
source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1
|
||||
for source_entity_id, source_edge in source_edges:
|
||||
if source_counts[source_entity_id] != 1:
|
||||
continue
|
||||
matches = [
|
||||
edge for edge in actual_edges
|
||||
if edge.wrapped.IsSame(source_edge.wrapped)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
mapped.append((source_entity_id, matches[0]))
|
||||
specs: list[dict[str, Any]] = [
|
||||
{
|
||||
"kind": "edge",
|
||||
"value": edge,
|
||||
"source_entity": (source_sketch_id, source_entity_id),
|
||||
}
|
||||
for source_entity_id, edge in mapped
|
||||
]
|
||||
vertex_groups: list[tuple[Any, set[str]]] = []
|
||||
for source_entity_id, edge in mapped:
|
||||
for vertex in edge.vertices():
|
||||
group = next(
|
||||
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
|
||||
None,
|
||||
)
|
||||
if group is None:
|
||||
vertex_groups.append((vertex, {source_entity_id}))
|
||||
else:
|
||||
group[1].add(source_entity_id)
|
||||
for vertex, entity_ids in vertex_groups:
|
||||
if len(entity_ids) < 2:
|
||||
continue
|
||||
specs.append({
|
||||
"kind": "vertex",
|
||||
"value": vertex,
|
||||
"source_entities": tuple(
|
||||
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
|
||||
),
|
||||
})
|
||||
return specs
|
||||
|
||||
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
|
||||
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
||||
@@ -211,6 +476,59 @@ class Build123dGeometryAdapter:
|
||||
faces.append(face.make_holes(holes) if holes else face)
|
||||
return faces
|
||||
|
||||
def _faces_from_circles_with_source_anchors(
|
||||
self,
|
||||
entities: list[dict[str, Any]],
|
||||
plane_spec: PlaneSpec,
|
||||
source_sketch_id: str | None,
|
||||
) -> tuple[list[Face], list[dict[str, Any]]]:
|
||||
"""Build direct circular profile faces while retaining their wire edges."""
|
||||
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
|
||||
if not circles:
|
||||
return [], []
|
||||
entries = []
|
||||
for item in circles:
|
||||
radius = float(item.get("radius_mm") or 0)
|
||||
if radius <= 0:
|
||||
continue
|
||||
center = [float(value) for value in item.get("center") or [0, 0]]
|
||||
entries.append({
|
||||
"center": center,
|
||||
"radius": radius,
|
||||
"wire": self._circle_wire(center, radius, plane_spec),
|
||||
"source_entity_id": item.get("source_entity_id"),
|
||||
})
|
||||
faces: list[Face] = []
|
||||
anchors: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
containing = sum(
|
||||
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
|
||||
for other in entries
|
||||
if other is not entry
|
||||
)
|
||||
if containing % 2:
|
||||
continue
|
||||
holes = [
|
||||
other for other in entries
|
||||
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
|
||||
and sum(
|
||||
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
|
||||
for candidate in entries
|
||||
if candidate is not other
|
||||
) == containing + 1
|
||||
]
|
||||
face = Face(entry["wire"])
|
||||
result = face.make_holes([item["wire"] for item in holes]) if holes else face
|
||||
source_edges = [
|
||||
(str(item["source_entity_id"]), edge)
|
||||
for item in [entry, *holes]
|
||||
if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"]
|
||||
for edge in item["wire"].edges()
|
||||
]
|
||||
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
|
||||
faces.append(result)
|
||||
return faces, anchors
|
||||
|
||||
@staticmethod
|
||||
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
|
||||
"""Return OCC split history, keeping an unchanged input as one image."""
|
||||
@@ -476,6 +794,96 @@ class Build123dGeometryAdapter:
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
||||
|
||||
def faces_for_sketch_with_source_anchors(
|
||||
self,
|
||||
sketch: dict[str, Any],
|
||||
) -> tuple[list[Face], list[dict[str, Any]]]:
|
||||
"""Return profile faces plus direct source anchors for prism history.
|
||||
|
||||
This intentionally covers only the direct analytic profile builders.
|
||||
IMPRINT, split, generated, or otherwise transformed profile paths keep
|
||||
their normal geometry but expose no semantic source anchor.
|
||||
"""
|
||||
profile = sketch.get("profile") or {}
|
||||
source_sketch_id = sketch.get("source_sketch_id")
|
||||
if profile.get("type") == "planar_imprint" or not isinstance(source_sketch_id, str):
|
||||
return self.faces_for_sketch(sketch), []
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
if isinstance(contours, list) and contours and all(
|
||||
isinstance(contour, dict)
|
||||
and bool(contour.get("closed"))
|
||||
and len(contour.get("segments") or []) == 1
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
):
|
||||
# Keep each original circular source edge before the sketch solver
|
||||
# expands it into analytic arc fragments for region bookkeeping.
|
||||
# ``_faces_from_circles_with_source_anchors`` verifies each wire
|
||||
# against the resulting face by exact ``IsSame``; a missing source
|
||||
# label remains unavailable rather than being inferred from its
|
||||
# radius or centre. This also covers a direct annulus or multiple
|
||||
# independent circular regions built by the same face constructor.
|
||||
circles = [
|
||||
{
|
||||
"type": "circle",
|
||||
"center": segment.get("center"),
|
||||
"radius_mm": segment.get("radius_mm"),
|
||||
**(
|
||||
{"source_entity_id": segment["source_entity_id"]}
|
||||
if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"]
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for contour in contours
|
||||
for segment in contour.get("segments") or []
|
||||
]
|
||||
return self._faces_from_circles_with_source_anchors(
|
||||
circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id,
|
||||
)
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if regions:
|
||||
# ``Face.make_holes`` remains the authoritative geometry path for
|
||||
# a mixed multi-region profile. OCC can give a valid direct face
|
||||
# for each individual region while changing how those regions
|
||||
# interact when their prisms are combined. The direct builder is
|
||||
# therefore limited to one hole-bearing region; keep the existing
|
||||
# executable profile and withhold anchors for the broader case.
|
||||
if len(regions) > 1 and any(region.get("holes") for region in regions):
|
||||
return self.faces_for_sketch(sketch), []
|
||||
logical_circle_sources = self._logical_circle_sources(profile)
|
||||
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None
|
||||
faces: list[Face] = []
|
||||
anchors: list[dict[str, Any]] = []
|
||||
for region in regions:
|
||||
outer = region.get("outer") or []
|
||||
if len(outer) < 1:
|
||||
continue
|
||||
# Build all direct boundary wires in one face builder. Each
|
||||
# prospective source edge is verified below against the final
|
||||
# face, so an OCC wire repair or an unsupported hole topology
|
||||
# removes evidence rather than creating a geometric fallback.
|
||||
try:
|
||||
result, source_edges = self._face_from_direct_wires(
|
||||
outer,
|
||||
region.get("holes") or (),
|
||||
logical_circle_sources=logical_circle_sources,
|
||||
plane_spec=plane_spec,
|
||||
)
|
||||
except ValueError:
|
||||
# Keep the established executable profile path when the
|
||||
# direct builder cannot represent this analytic region.
|
||||
# It deliberately carries no provenance anchor.
|
||||
return self.faces_for_sketch(sketch), []
|
||||
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
|
||||
faces.append(result)
|
||||
return faces, anchors
|
||||
if profile.get("type") == "circle":
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles_with_source_anchors(
|
||||
sketch.get("entities") or [], plane, source_sketch_id,
|
||||
)
|
||||
return self.faces_for_sketch(sketch), []
|
||||
|
||||
@staticmethod
|
||||
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
|
||||
"""Build one planar profile from a sketch outer wire and cap-face holes."""
|
||||
@@ -582,7 +990,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
|
||||
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
|
||||
"""Extrude one face with exact cap, side-wall, and swept-edge history."""
|
||||
vector = _vector(direction)
|
||||
if vector.length <= 1e-9:
|
||||
raise ValueError("extrude direction must be non-zero")
|
||||
@@ -600,6 +1008,46 @@ class Build123dGeometryAdapter:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", face.wrapped, (output,), output_role=role,
|
||||
))
|
||||
for source_edge in face.edges():
|
||||
# ``Generated(edge)`` proves the lateral face only. Prism's
|
||||
# source-edge overloads preserve the distinct start/end cap-edge
|
||||
# mapping, verified against the final result snapshot here.
|
||||
for role, cap_edge in (
|
||||
("extrude.start", builder.FirstShape(source_edge.wrapped)),
|
||||
("extrude.end", builder.LastShape(source_edge.wrapped)),
|
||||
):
|
||||
is_final_edge = (
|
||||
not cap_edge.IsNull()
|
||||
and cap_edge.ShapeType() == TopAbs_EDGE
|
||||
and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges())
|
||||
)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "edge", source_edge.wrapped,
|
||||
(cap_edge,) if is_final_edge else (),
|
||||
output_role=role,
|
||||
source_kind="edge",
|
||||
result_kind="edge",
|
||||
derivation="boundary",
|
||||
coverage="complete" if is_final_edge else "partial",
|
||||
status="proven" if is_final_edge else "unknown",
|
||||
))
|
||||
generated = tuple(builder.Generated(source_edge.wrapped))
|
||||
side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "edge", source_edge.wrapped, side_faces,
|
||||
source_kind="edge", result_kind="face", derivation="boundary",
|
||||
coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial",
|
||||
status="proven" if len(side_faces) == len(generated) and side_faces else "unknown",
|
||||
))
|
||||
for source_vertex in source_edge.vertices():
|
||||
generated_edges = tuple(builder.Generated(source_vertex.wrapped))
|
||||
swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "vertex", source_vertex.wrapped, swept_edges,
|
||||
source_kind="vertex", result_kind="edge", derivation="boundary",
|
||||
coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial",
|
||||
status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown",
|
||||
))
|
||||
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
|
||||
|
||||
@staticmethod
|
||||
@@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter:
|
||||
@staticmethod
|
||||
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
|
||||
"""Translate OCC builder history into adapter-neutral opaque relations."""
|
||||
source_bodies = tuple(sources)
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for source in sources:
|
||||
source_faces: list[tuple[int, Any]] = []
|
||||
for source_index, source in enumerate(source_bodies):
|
||||
source_faces.extend((source_index, face.wrapped) for face in source.faces())
|
||||
for kind, shapes in (
|
||||
("face", list(source.faces())),
|
||||
("edge", list(source.edges())),
|
||||
@@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter:
|
||||
if generated:
|
||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||
section_values: tuple[Any, ...] = ()
|
||||
section_relations: list[TopologySectionRelation] = []
|
||||
section_edges = getattr(operation, "SectionEdges", None)
|
||||
if callable(section_edges):
|
||||
try:
|
||||
@@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter:
|
||||
section_values = tuple(section_edges())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
section_values = ()
|
||||
if section_values and len(source_bodies) >= 2:
|
||||
# A section edge becomes source-qualified only when OCC returns
|
||||
# that exact edge from Generated(face) for one face from each
|
||||
# boolean input. Do not inspect BOPDS internals here: those
|
||||
# Python bindings are unsafe for this traversal and ordinary
|
||||
# SectionEdges remain useful unqualified diagnostics.
|
||||
for section_edge in section_values:
|
||||
generators: list[tuple[int, Any]] = []
|
||||
for source_index, source_face in source_faces:
|
||||
try:
|
||||
generated = tuple(operation.Generated(source_face))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
generated = ()
|
||||
if any(candidate.IsSame(section_edge) for candidate in generated):
|
||||
generators.append((source_index, source_face))
|
||||
if len(generators) != 2 or {item[0] for item in generators} != {0, 1}:
|
||||
continue
|
||||
section_relations.append(TopologySectionRelation(
|
||||
source_values=(generators[0][1], generators[1][1]),
|
||||
result_value=section_edge,
|
||||
))
|
||||
qualified_section_values = tuple(item.result_value for item in section_relations)
|
||||
unqualified_section_values = tuple(
|
||||
value for value in section_values
|
||||
if not any(value.IsSame(qualified) for qualified in qualified_section_values)
|
||||
)
|
||||
return TopologyDelta(
|
||||
operation=operation_name,
|
||||
relations=tuple(relations),
|
||||
section_values=section_values,
|
||||
section_values=unqualified_section_values,
|
||||
section_relations=tuple(section_relations),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter:
|
||||
end_tangent: Vector3 | None = None,
|
||||
parameters: list[float] | None = None,
|
||||
) -> Edge | Wire:
|
||||
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
|
||||
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
|
||||
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
|
||||
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
|
||||
# 缺失时不能伪造,交给内核自动求解。
|
||||
vertices = [_vector(point) for point in points]
|
||||
if len(vertices) < 2:
|
||||
raise ValueError("sweep path needs at least two points")
|
||||
if len(vertices) == 2:
|
||||
if start_tangent is not None or end_tangent is not None or parameters is not None:
|
||||
raise ValueError("line sweep path does not accept B-spline tangents")
|
||||
return Edge.make_line(vertices[0], vertices[1])
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("sweep B-spline path requires both endpoint tangents")
|
||||
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
|
||||
if parameters is not None and len(parameters) != len(vertices):
|
||||
raise ValueError("sweep B-spline path parameters must match point count")
|
||||
if len(vertices) == 2 and tangents is None:
|
||||
if parameters is not None:
|
||||
raise ValueError("line sweep path does not accept B-spline parameters")
|
||||
return Edge.make_line(vertices[0], vertices[1])
|
||||
direction = vertices[-1] - vertices[0]
|
||||
tolerance = 1e-9 * max(1.0, direction.length)
|
||||
collinear_points = direction.length > tolerance and all(
|
||||
|
||||
Reference in New Issue
Block a user