This commit is contained in:
2026-08-27 19:07:57 +08:00
18 changed files with 2848 additions and 80 deletions
+122 -24
View File
@@ -44,6 +44,21 @@ class FeatureExecutionError(RuntimeError):
self.detail = detail
@dataclass(frozen=True)
class ExtentVector:
"""Single-directional extrusion displacement for one profile face.
``trim_to`` stays ``None`` for an exact vector extrusion. When set, the
extent means "extrude until the target face, trimming any profile region
that does not reach it" (up_to_surface trim semantics, issue #5). The
piercing distance is computed inside the adapter, so ``vector`` only
supplies the direction.
"""
vector: Vector3
trim_to: Any | None = None
class AtomicExecutor(Protocol):
atomic_id: str
@@ -59,9 +74,11 @@ class GeometryAdapter(Protocol):
"""
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
@@ -92,9 +109,23 @@ class ExecutionSession:
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body
self.body_id = f"body:{feature_id}"
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else:
for index, solid in enumerate(solids):
member_id = f"{self.body_id}:{index}"
self.topology.replace_body_topology(
feature_id, member_id,
self.adapter.topology_records(solid, feature_id, member_id),
active_body_id=self.body_id,
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
@@ -163,7 +194,7 @@ def _targeted_extent_vector(
*,
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> Vector3:
) -> ExtentVector:
if session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
@@ -197,8 +228,20 @@ def _targeted_extent_vector(
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
raise FeatureExecutionError(code, str(error), extent=condition) from error
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition == "up_to_surface":
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
# "not reached",保持显式拒绝。
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
raise FeatureExecutionError(code, message, extent=condition) from error
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
distance -= offset
@@ -208,7 +251,7 @@ def _targeted_extent_vector(
"Offset distance reaches or passes the target surface",
extent=condition, offset_mm=offset,
)
return vector_scale(direction, distance)
return ExtentVector(vector_scale(direction, distance))
def _side_extent_vectors(
@@ -219,7 +262,7 @@ def _side_extent_vectors(
*,
end_condition: dict[str, Any],
distance_mm: float,
) -> list[Vector3]:
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` calls this once for each independently captured
@@ -231,17 +274,22 @@ def _side_extent_vectors(
if condition == "blind":
if distance <= 0:
raise ValueError("blind extent requires distance_mm > 0")
return [vector_scale(direction, distance)]
return [ExtentVector(vector_scale(direction, distance))]
if condition == "mid_plane":
if distance <= 0:
raise ValueError("mid_plane extent requires distance_mm > 0")
return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)]
return [
ExtentVector(vector_scale(direction, distance / 2)),
ExtentVector(vector_scale(direction, -distance / 2)),
]
if condition == "through_all":
if session.body is None:
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [direction * distance]
return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
return [ExtentVector(vector_scale(direction, distance))]
return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))]
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
return [
_targeted_extent_vector(
@@ -257,7 +305,7 @@ def _extent_vectors(
faces: list[Any],
sketch: dict[str, Any],
session: ExecutionSession,
) -> list[Vector3]:
) -> list[ExtentVector]:
params = node.params
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(params.get("reverse")):
@@ -285,16 +333,19 @@ def _extent_vectors(
# against. The source must provide a usable blind component.
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [vector_scale(normal, distance)]
return [ExtentVector(vector_scale(normal, distance))]
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
if condition == "through_all":
return [vector_scale(normal, span)]
return [ExtentVector(vector_scale(normal, span))]
if condition == "through_all_both":
return [vector_scale(normal, span), vector_scale(normal, -span)]
return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))]
# Through-all-and-blind is represented by a through direction plus
# its captured opposite blind direction when available.
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))]
return [
ExtentVector(vector_scale(normal, span)),
ExtentVector(vector_scale(normal, -(reverse_distance or span))),
]
return _side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
)
@@ -337,15 +388,30 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。
vectors = _extent_vectors(node, faces, selected_sketch, session)
solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
extents = _extent_vectors(node, faces, selected_sketch, session)
solids: list[Any] = []
for face in faces:
for extent in extents:
if extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("revolve requires angle_deg > 0")
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
# 侧实现,使三方合同一致。
if bool(node.params.get("reverse")):
angle = -angle
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
tool = None
@@ -498,8 +564,18 @@ def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: b
session.adapter.body_span(session.body, inward) + 2.0,
)
# 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
diagnostics: list[RuntimeDiagnostic] = []
if wizard and node.params.get("thread"):
diagnostics.append(RuntimeDiagnostic(
code="thread_decoration_ignored",
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id,
))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
return session.result(node, diagnostics=diagnostics)
def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]:
@@ -547,12 +623,21 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
distance = float(node.params.get("distance_mm") or 0)
if distance <= 0:
raise ValueError("chamfer distance_mm must be > 0")
# 3. 解析目标边(支持相切传播),执行倒角;distance_2_mm 提供时产生非对称倒角。
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
# 倒角仅保证量级正确,距离所在侧可能反转。
distance_2 = node.params.get("distance_2_mm")
angle_rad = node.params.get("angle_rad")
if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。
body = session.adapter.chamfer(
session.body, distance, node.params.get("distance_2_mm"),
session.body, distance, distance_2,
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
# 4. 登记新主体并返回结果。
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
@@ -706,9 +791,20 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) ->
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
)
if positions_are_local:
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
for key in ("origin_mm", "x_dir", "normal"):
if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
x_reflected = host_frame.get("x_dir")
n_reflected = host_frame.get("normal")
if x_reflected is not None and n_reflected is not None:
host_frame["y_dir"] = [
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
]
# See _mirrored_sketch: the canonical reflected plane reverses local
# y, so local hole coordinates must do the same.
for position in params.get("positions") or []:
@@ -899,7 +995,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
"solid_count": int(geometry.get("solid_count") or 0),
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
"solid_count": len(session.adapter.body_solids(session.body)),
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],