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:
2026-09-10 15:12:57 +08:00
parent d8fc2a9207
commit 994d06aaea
36 changed files with 9268 additions and 753 deletions
+498 -18
View File
@@ -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(