From 9160c2baceab50e2fdc52fa83e6fa73d1c9ceaeb Mon Sep 17 00:00:00 2001 From: ganjihong Date: Mon, 7 Sep 2026 15:25:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=E5=A2=9E=E5=8A=A0=E5=8A=9F=E8=83=BD=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../engine/cdsl_engine/build123d_adapter.py | 58 +++- backend/engine/cdsl_engine/capabilities.py | 23 +- backend/engine/cdsl_engine/cdsl_schema.json | 37 ++- .../engine/cdsl_engine/parametric_thread.py | 13 +- .../engine/cdsl_engine/profile_schema.json | 5 +- backend/engine/cdsl_engine/runtime.py | 286 +++++++++++++++++- .../test_engine_circular_pattern_geometry.py | 166 ++++++++++ 7 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_engine_circular_pattern_geometry.py diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 632b4858..105ba1b3 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Compound, Edge, Face, Helix, Location, Plane, ShapeList, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step from .parametric_thread import build_thread_solid from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -163,8 +163,11 @@ class Build123dGeometryAdapter: return None if len(members) == 1: return members[0] - # build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。 - return Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue] + # build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域 + # 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在, + # 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明 + # make_composite,保留宽泛类型桩噪音抑制。 + return Compound.make_composite(members) @staticmethod def extrude(face: Face, direction: Vector3) -> Solid: @@ -438,9 +441,27 @@ class Build123dGeometryAdapter: return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face) @staticmethod - def sweep(section: Face, spine: Edge | Wire) -> Solid: + def sweep( + section: Face | Wire, + spine: Edge | Wire, + *, + inner_wires: list[Wire] | None = None, + make_solid: bool = True, + is_frenet: bool = False, + transition: Any = None, + ) -> Solid: # 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。 - return Solid.sweep(section, spine) + # 默认值对齐 build123d Solid.sweep:make_solid=True 封盖成体;is_frenet=True + # 使截面沿路径 Frenet 标架取向保持恒定(螺纹/花键"键侧平行"所需); + # transition 为 None 时交给 build123d 默认的 Transition.TRANSFORMED。 + sweep_options: dict[str, Any] = { + "inner_wires": inner_wires, + "make_solid": make_solid, + "is_frenet": is_frenet, + } + if transition is not None: + sweep_options["transition"] = transition + return Solid.sweep(section, spine, **sweep_options) @staticmethod def sweep_path(points: Iterable[Vector3]) -> Wire: @@ -451,15 +472,32 @@ class Build123dGeometryAdapter: return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)]) @staticmethod - def helix_path(radius_mm: float, pitch_mm: float, turns: float, *, lefthand: bool = False) -> Edge: + def helix_path( + radius_mm: float, + pitch_mm: float, + turns: float | None = None, + *, + height_mm: float | None = None, + lefthand: bool = False, + ) -> Edge: # 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。 # 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。 + # turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键, + # 按高度适配 parametric_thread(length + 两端余量)。构造与 parametric_thread + # 原 Edge.make_helix 同源,保证生成器复用后逐位一致。 if pitch_mm <= 0: raise ValueError("helix pitch must be positive") - if turns <= 0: - raise ValueError("helix turns must be positive") - helix = Helix(pitch=pitch_mm, height=turns * pitch_mm, radius=radius_mm, lefthand=lefthand) - return helix.edges()[0] + if (turns is None) == (height_mm is None): + raise ValueError("helix_path needs exactly one of turns or height_mm") + if turns is not None: + if turns <= 0: + raise ValueError("helix turns must be positive") + height = turns * pitch_mm + else: + if height_mm <= 0: + raise ValueError("helix height must be positive") + height = height_mm + return Edge.make_helix(pitch=pitch_mm, height=height, radius=radius_mm, lefthand=lefthand) @staticmethod def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any: diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index ea2c2440..5516b186 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -21,7 +21,7 @@ _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_") _PRIMARY_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", }) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) _ACTIVE_BODY_REQUIRED = frozenset({ @@ -32,8 +32,8 @@ _ACTIVE_BODY_REQUIRED = frozenset({ }) _BODY_MUTATING_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "revolve_add", "revolve_cut", "sphere_add", "thread_add", "thread_cut", - *_HOLE_ATOMICS, "fillet", "chamfer", + "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", + "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", }) # A pattern may replay a previous pattern as well as a direct body mutation. # Context-only features have no geometry definition to instance. thread_add @@ -42,7 +42,7 @@ _BODY_MUTATING_ATOMICS = frozenset({ # location. _REPLAYABLE_ATOMICS = ( _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"}) -) | frozenset({"pattern_linear", "pattern_mirror"}) +) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body", @@ -428,6 +428,18 @@ class CapabilityAnalyzer: )) if node.atomic_id == "pattern_mirror" and not params.get("mirror_plane"): blockers.append(self._blocker(node.feature_id, "missing_mirror_plane", "Mirror pattern has no mirror plane")) + if node.atomic_id == "pattern_circular": + if not _has_explicit_axis(params.get("axis")): + blockers.append(self._blocker( + node.feature_id, "missing_circular_axis", + "Circular pattern requires an explicit axis with origin_mm and direction", + )) + pattern_count = params.get("pattern_count") + if pattern_count is None or int(pattern_count) < 1: + blockers.append(self._blocker( + node.feature_id, "invalid_pattern_count", + "Circular pattern requires pattern_count >= 1", + )) status = "executable" if not blockers else ("unsupported" if any(b.code.startswith("unsupported") or b.code == "unknown_atomic" for b in blockers) else "blocked") results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers))) if status == "executable": @@ -436,7 +448,8 @@ class CapabilityAnalyzer: body_available = True body_producers = { "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", - "revolve_add", "revolve_cut", "sphere_add", "thread_add", + "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", + "thread_add", # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。 "thread_cut", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 312c37c4..90d4630e 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -117,6 +117,27 @@ "required": ["radius_mm", "center_mm"], "additionalProperties": false }, + "boxParams": { + "type": "object", + "properties": { + "length_mm": {"$ref": "#/$defs/positive"}, + "width_mm": {"$ref": "#/$defs/positive"}, + "height_mm": {"$ref": "#/$defs/positive"}, + "center_mm": {"$ref": "#/$defs/point3"} + }, + "required": ["length_mm", "width_mm", "height_mm", "center_mm"], + "additionalProperties": false + }, + "cylinderParams": { + "type": "object", + "properties": { + "radius_mm": {"$ref": "#/$defs/positive"}, + "height_mm": {"$ref": "#/$defs/positive"}, + "axis": {"$ref": "#/$defs/axis"} + }, + "required": ["radius_mm", "height_mm"], + "additionalProperties": false + }, "threadAddParams": { "type": "object", "properties": { @@ -242,6 +263,17 @@ "required": ["source_feature_ids", "mirror_plane"], "additionalProperties": false }, + "circularPatternParams": { + "type": "object", + "properties": { + "source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, + "axis": {"$ref": "#/$defs/axis"}, + "pattern_count": {"type": "integer", "minimum": 1}, + "sweep_angle_deg": {"type": "number", "minimum": -360, "maximum": 360} + }, + "required": ["source_feature_ids", "axis", "pattern_count"], + "additionalProperties": false + }, "referencePlaneParams": { "type": "object", "properties": { @@ -351,7 +383,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "reference_plane", "reference_axis", "hole_wizard"]}, + "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -374,6 +406,8 @@ {"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "box_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/boxParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "cylinder_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/cylinderParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "thread_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadAddParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "thread_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadCutParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}}, @@ -383,6 +417,7 @@ {"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}}, + {"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}}, {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} diff --git a/backend/engine/cdsl_engine/parametric_thread.py b/backend/engine/cdsl_engine/parametric_thread.py index c2e12f6d..70b01ff6 100644 --- a/backend/engine/cdsl_engine/parametric_thread.py +++ b/backend/engine/cdsl_engine/parametric_thread.py @@ -110,11 +110,12 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid: # material; the crest centre sits at the helix start phase (z = 0), which # also puts the z = 0 end face through full crest material after trimming. helix_height = spec.length_mm + 2.0 * pitch - helix = Edge.make_helix( - pitch=pitch, - height=helix_height, - radius=root_radius, - lefthand=spec.lefthand, + # 复用 adapter 门面构造螺旋路径与扫掠。函数级延迟导入:build123d_adapter 顶部 + # 已 import build_thread_solid,模块级反向 import adapter 会形成循环依赖。 + from .build123d_adapter import Build123dGeometryAdapter + + helix = Build123dGeometryAdapter.helix_path( + root_radius, pitch, height_mm=helix_height, lefthand=spec.lefthand, ) # Profile vertices (z, r) -> world (x = r, y = 0, z). Order is counter @@ -130,7 +131,7 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid: ] wire = Wire([Edge.make_line(pts[index], pts[(index + 1) % len(pts)]) for index in range(len(pts))]) wire = _apply_profile_fillets(wire, spec, crest_radius, sunk_root, pitch, crest_half_width) - ribbon = Solid.sweep(section=Face(wire), path=helix, make_solid=True, is_frenet=True) + ribbon = Build123dGeometryAdapter.sweep(Face(wire), helix, make_solid=True, is_frenet=True) # Core cylinder at the nominal minor radius spanning the whole helix. # (The sunken tooth roots overlap it so the union below is clean.) diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 99c8b2d2..f519eeaf 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -15,6 +15,8 @@ "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, + "box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, + "cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]}, @@ -52,7 +54,8 @@ "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}, - "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]} + "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}, + "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]} }, "profiles": { "circle": { diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index fd249122..dc27b745 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -22,8 +22,10 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", + "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror", + "pattern_circular", "thread_add", "thread_cut", }) @@ -513,6 +515,56 @@ def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> Feature return session.result(node) +def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 + # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 + try: + length = float(node.params.get("length_mm") or 0.0) + width = float(node.params.get("width_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + center = node.params.get("center_mm") or [] + except (TypeError, ValueError) as error: + raise ValueError("box dimensions must be numeric") from error + if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: + raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") + # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 + # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 + corner = ( + float(center[0]) - length / 2, + float(center[1]) - width / 2, + float(center[2]) - height / 2, + ) + plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) + solid = session.adapter.box(length, width, height, plane) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; + # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 + # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 + try: + radius = float(node.params.get("radius_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("cylinder dimensions must be numeric") from error + if radius <= 0 or height <= 0: + raise ValueError("cylinder_add requires positive radius_mm and height_mm") + raw_axis = node.params.get("axis") + if raw_axis is not None and not ( + isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None + ): + raise ValueError("cylinder_add axis must define origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None + # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 + solid = session.adapter.cylinder(radius, height, axis) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 @@ -754,6 +806,11 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s axis = params.get("axis") or {} if axis.get("origin_mm"): axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] + center = params.get("center_mm") + if center: + # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, + # 否则阵列副本会静默重合在原位置。 + params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] mirror_plane = params.get("mirror_plane") if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 @@ -930,6 +987,12 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane) if axis.get("direction"): axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + # box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定 + # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern + # 中已被显式拒绝)。 + params["center_mm"] = _reflect_point(center, plane) mirror_plane = params.get("mirror_plane") if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 @@ -971,6 +1034,15 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) +def _normal_is_coordinate_axis(normal: Any) -> bool: + # 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。 + return ( + isinstance(normal, (list, tuple)) + and len(normal) == 3 + and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3)) + ) + + def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: mirror = node.params.get("mirror_plane") or {} resolution = session.resolve(mirror) @@ -983,6 +1055,11 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> dependency = pattern_transform_blocker(source) if dependency: raise ValueError(f"mirror pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): + # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, + # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 + # (法向平行于任一坐标轴)的镜像仍然精确。 + raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) sketch = session.sketches.get(str(source.sketch_id)) _execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) @@ -990,6 +1067,200 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> return session.result(node) +def _coordinate_axis_direction(direction: Any) -> bool: + # 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。 + # 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向 + # (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。 + if not isinstance(direction, (list, tuple)) or len(direction) != 3: + return False + return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1 + + +def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3: + # Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。 + cosine = math.cos(angle_rad) + sine = math.sin(angle_rad) + axis_direction = axis.direction + cross = vector_cross(axis_direction, value) + dot = vector_dot(axis_direction, value) + return tuple( # type: ignore[return-value] + value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine) + for index in range(3) + ) + + +def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]: + # 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。 + value = tuple(float(component) for component in point) + relative = vector_subtract(value, axis.origin_mm) + rotated = _rotated_vector(relative, axis, angle_rad) + return [axis.origin_mm[index] + rotated[index] for index in range(3)] + + +def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]: + # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; + # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 + # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center + # 世界坐标点绕轴旋转。 + output = deepcopy(sketch) + workplane = output.get("workplane") or {} + if workplane.get("origin_mm"): + workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if workplane.get(key): + workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad)) + output["workplane"] = workplane + + def rotate(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = _rotated_point(value[point_key], axis, angle_rad) + for child in value.values(): + rotate(child) + elif isinstance(value, list): + for child in value: + rotate(child) + + for key in ("contour_edges_mm", "contour_regions_mm"): + rotate(output.get(key)) + return output + + +def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: ExecutionSession) -> FeaturePlanNode: + # 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与 + # _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转, + # 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/ + # 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern + # source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层 + # 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。 + params = deepcopy(node.params) + plane = params.get("plane") + if isinstance(plane, dict): + for key in ("origin_mm", "x_dir", "y_dir", "normal"): + if plane.get(key): + if key == "origin_mm": + plane[key] = _rotated_point(plane[key], axis, angle_rad) + else: + plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local: + if host_frame.get("origin_mm"): + host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad) + for key in ("x_dir", "normal"): + if host_frame.get(key): + host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad)) + else: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = _rotated_point(position["mm"], axis, angle_rad) + feature_axis = params.get("axis") + if isinstance(feature_axis, dict): + if feature_axis.get("origin_mm"): + feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad) + if feature_axis.get("direction"): + feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad)) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + params["center_mm"] = _rotated_point(center, axis, angle_rad) + if node.atomic_id in {"pattern_mirror", "pattern_circular"}: + # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 + # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 + if node.atomic_id == "pattern_mirror": + mirror_plane = params.get("mirror_plane") + if not isinstance(mirror_plane, dict): + raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference") + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for circular pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad), + "x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)), + "normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: + # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 + # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 + params = node.params + raw_axis = params.get("axis") + if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): + raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) + count = int(params.get("pattern_count") or 1) + if count < 1: + raise ValueError("circular pattern pattern_count must be >= 1") + sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) + sources = session.replay_sources(params.get("source_feature_ids") or []) + if not sources: + raise ValueError("circular pattern source features have no replay definitions") + for instance in range(1, count): + # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 + angle_deg = sweep_angle_deg * instance / count + angle_rad = math.radians(angle_deg) + for source in sources: + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"circular pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): + raise ValueError( + "box_add circular pattern is exact only for coordinate-axis rotation " + "by multiples of 180 degrees" + ) + cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) + sketch = session.sketches.get(str(source.sketch_id)) + execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) + # 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。 + session.replay_definitions[node.feature_id] = node + return session.result(node) + + +def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool: + # box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴, + # 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确 + # (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的 + # box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。 + if not _coordinate_axis_direction(axis.direction): + return False + half_turns = abs(math.degrees(angle_rad)) / 180.0 + return abs(half_turns - round(half_turns)) < 1e-9 + + +def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_circular_pattern(node, session, _execute_node) + + def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult: executor = EXECUTORS.get(node.atomic_id) if executor is None: @@ -1024,6 +1295,16 @@ def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: d return _execute_sphere(node, session) +def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_box(node, session) + + +def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_cylinder(node, session) + + def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_hole(node, session) @@ -1058,6 +1339,8 @@ EXECUTORS: dict[str, ExecutorFunction] = { "reference_plane": _reference_plane_executor, "reference_axis": _reference_axis_executor, "sphere_add": _sphere_executor, + "box_add": _box_executor, + "cylinder_add": _cylinder_executor, "thread_add": _thread_executor, "thread_cut": _thread_executor, "extrude_add_blind": _primary_executor, @@ -1073,6 +1356,7 @@ EXECUTORS: dict[str, ExecutorFunction] = { "chamfer": _chamfer_executor, "pattern_linear": _linear_pattern_executor, "pattern_mirror": _mirror_pattern_executor, + "pattern_circular": _circular_pattern_executor, } diff --git a/backend/tests/test_engine_circular_pattern_geometry.py b/backend/tests/test_engine_circular_pattern_geometry.py new file mode 100644 index 00000000..e236d4d0 --- /dev/null +++ b/backend/tests/test_engine_circular_pattern_geometry.py @@ -0,0 +1,166 @@ +"""N2 pattern_circular —— 旋转几何契约层测试。 + +中文说明 +-------- +统计报告把 N2(直线/环形阵列)归为 A 类;runtime 的 pattern 采用源特征 +重放范式(pattern_linear/_translated_node、pattern_mirror/_mirrored_node), +本次为 pattern_circular 补齐了缺失的"环形重放实现入口":旋转重放节点 +(_rotated_node)与 Rodrigues 旋转辅助(_rotated_vector/_rotated_point)。 + +本文件是三个测试角度中的**几何单元层**:只验证旋转数学与草图/参数变换 +函数的正确性,不驱动 executor、不跑布尔运算。旋转是环形阵列几何正确性 +的全部基础——若绕轴旋转的坐标变换有误,任何 count/包角组合都会产出错误 +实例位置。runtime 端到端层见 test_engine_circular_pattern_runtime.py。 + +契约要点(与 runtime 层的分工): + 1. Rodrigues 旋转是等距变换:轴向分量守恒、长度/到轴距离不变、 + 非轴向按 cos/sin 旋转 → 用 90°/180° 与随机向量精确断言坐标。 + 2. 方向约定:绕轴 direction 正角度为右手逆时针(+Z 轴把 +X 转到 +Y)。 + 3. _rotated_sketch 只旋转世界坐标(workplane frame 与 start/end/center_mm), + 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。 + 4. _box_circular_is_exact:box_add 是固定世界轴对齐图元,仅坐标轴旋转 + 且每份转角为 180° 整数倍时才精确(对齐 _execute_mirror_pattern 的 + box 坐标平面限制思路)。 + +sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import +cdsl_engine 包内模块直测 adapter(与既有测试风格一致)。 +""" + +from __future__ import annotations + +import math +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) +sys.path.insert(0, str(ROOT / "backend" / "engine")) + +from cdsl_engine.runtime import ( # noqa: E402 + _box_circular_is_exact, + _coordinate_axis_direction, + _rotated_point, + _rotated_sketch, + _rotated_vector, +) +from cdsl_engine.runtime_types import AxisSpec # noqa: E402 + + +X_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(1.0, 0.0, 0.0)) +Y_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 1.0, 0.0)) +Z_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0)) +# Rodrigues 要求单位轴(运行时 AxisSpec.from_mapping 会单位化);测试直接 +# 构造 dataclass,必须显式传入单位方向。 +_UNIT_DIAGONAL = math.sqrt(1.0 / 3.0) +SKEW_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), + direction=(_UNIT_DIAGONAL, _UNIT_DIAGONAL, _UNIT_DIAGONAL)) + + +def _assert_vector_close(test: unittest.TestCase, actual, expected, *, places: int = 9) -> None: + for actual_component, expected_component in zip(actual, expected): + test.assertAlmostEqual(float(actual_component), float(expected_component), places=places) + + +class CircularPatternGeometryTests(unittest.TestCase): + def test_rotated_point_around_z_axis_cardinal_angles(self) -> None: + # +Z 轴右手逆时针:90° 把 (10, 0, 0) 转到 (0, 10, 0),180° 到 (-10, 0, 0), + # 270° 到 (0, -10, 0)。z 坐标不变。 + point = (10.0, 0.0, 4.0) + for angle_deg, expected in [ + (90.0, (0.0, 10.0, 4.0)), + (180.0, (-10.0, 0.0, 4.0)), + (270.0, (0.0, -10.0, 4.0)), + (360.0, (10.0, 0.0, 4.0)), + ]: + with self.subTest(angle=angle_deg): + rotated = _rotated_point(point, Z_AXIS, math.radians(angle_deg)) + _assert_vector_close(self, rotated, expected) + + def test_rotated_point_negative_angle_rotates_clockwise(self) -> None: + # -90° 顺时针:+X 转到 -Y。 + rotated = _rotated_point((10.0, 0.0, 0.0), Z_AXIS, math.radians(-90.0)) + _assert_vector_close(self, rotated, (0.0, -10.0, 0.0)) + + def test_rotated_point_about_arbitrary_axis_keeps_axis_distance(self) -> None: + # 绕空间对角轴:轴向分量守恒、到轴距离不变(等距变换)。 + point = (3.0, -2.0, 7.0) + rotated = _rotated_point(point, SKEW_AXIS, math.radians(40.0)) + axial = sum(component * value for component, value in zip(point, SKEW_AXIS.direction)) + rotated_axial = sum(component * value for component, value in zip(rotated, SKEW_AXIS.direction)) + self.assertAlmostEqual(rotated_axial, axial, places=9) + distance_sq = sum(c * c for c in point) - axial * axial + rotated_distance_sq = sum(c * c for c in rotated) - rotated_axial * rotated_axial + self.assertAlmostEqual(rotated_distance_sq, distance_sq, places=9) + + def test_rotated_point_around_offset_axis(self) -> None: + # 轴不过原点:先平移到轴、旋转、再平移回。绕 (10, 0, 0) 竖直轴转 90°, + # 点 (10, 5, 0) 的相对矢量 (0, 5, 0) 转到 (-5, 0, 0) → (5, 0, 0)。 + offset_axis = AxisSpec(origin_mm=(10.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0)) + rotated = _rotated_point((10.0, 5.0, 0.0), offset_axis, math.radians(90.0)) + _assert_vector_close(self, rotated, (5.0, 0.0, 0.0)) + + def test_rotated_vector_preserves_length_and_normalizes_axis(self) -> None: + # 向量旋转不含平移项(原点到向量尾所在轴上的投影分量守恒)。 + value = (2.0, 0.0, 0.0) + rotated = _rotated_vector(value, Z_AXIS, math.radians(90.0)) + _assert_vector_close(self, rotated, (0.0, 2.0, 0.0)) + for axis, expected in [ + (X_AXIS, (2.0, 0.0, 0.0)), # 绕自身轴旋转不变 + (Y_AXIS, (0.0, 0.0, -2.0)), # 绕 +Y 转 90°(右手)把 +X 转到 -Z + ]: + with self.subTest(axis=axis.direction): + _assert_vector_close(self, _rotated_vector(value, axis, math.radians(90.0)), expected) + + def test_rotated_vector_arbitrary_angle_is_length_preserving(self) -> None: + value = (1.0, 2.0, 3.0) + rotated = _rotated_vector(value, SKEW_AXIS, math.radians(71.0)) + self.assertAlmostEqual( + sum(c * c for c in rotated), sum(c * c for c in value), places=9) + + def test_rotated_sketch_rotates_workplane_and_world_contours_only(self) -> None: + # workplane frame(原点 + 三向量)与 contour 世界坐标点绕 +Z 转 90°; + # 2D 局部实体坐标保持原样(frame 旋转负责映射到新世界位置)。 + sketch = { + "workplane": {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}, + "entities": [{"type": "circle", "center": [0.0, 1.0], "radius_mm": 2.0}], + "contour_edges_mm": [{"start_mm": [1.0, 2.0, 0.0], "end_mm": [3.0, 4.0, 5.0], + "center_mm": [2.0, 3.0, 0.0]}], + "contour_regions_mm": [{"outer": [{"start_mm": [0.0, 1.0, 0.0], + "end_mm": [1.0, 0.0, 0.0]}]}], + } + rotated = _rotated_sketch(sketch, Z_AXIS, math.radians(90.0)) + _assert_vector_close(self, rotated["workplane"]["origin_mm"], (0.0, 0.0, 0.0)) + _assert_vector_close(self, rotated["workplane"]["x_dir"], (0.0, 1.0, 0.0)) + _assert_vector_close(self, rotated["workplane"]["y_dir"], (-1.0, 0.0, 0.0)) + _assert_vector_close(self, rotated["workplane"]["normal"], (0.0, 0.0, 1.0)) + self.assertEqual(rotated["entities"], sketch["entities"]) + edge = rotated["contour_edges_mm"][0] + _assert_vector_close(self, edge["start_mm"], (-2.0, 1.0, 0.0)) + _assert_vector_close(self, edge["end_mm"], (-4.0, 3.0, 5.0)) + _assert_vector_close(self, edge["center_mm"], (-3.0, 2.0, 0.0)) + outer = rotated["contour_regions_mm"][0]["outer"][0] + _assert_vector_close(self, outer["start_mm"], (-1.0, 0.0, 0.0)) + _assert_vector_close(self, outer["end_mm"], (0.0, 1.0, 0.0)) + + def test_box_circular_exactness_boundary(self) -> None: + # box_add 固定世界轴对齐:仅坐标轴旋转且每份转角为 180° 整数倍时精确。 + self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(180.0))) + self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(360.0))) + self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(90.0))) + self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(45.0))) + # 绕 x/y 的非 180° 转角同样不可精确表达;空间对角轴任何转角都不行。 + self.assertFalse(_box_circular_is_exact(X_AXIS, math.radians(90.0))) + self.assertFalse(_box_circular_is_exact(SKEW_AXIS, math.radians(180.0))) + + def test_coordinate_axis_direction_detection(self) -> None: + self.assertTrue(_coordinate_axis_direction([0.0, 0.0, 1.0])) + self.assertTrue(_coordinate_axis_direction((0.0, -1.0, 0.0))) + self.assertFalse(_coordinate_axis_direction([1.0, 1.0, 1.0])) + self.assertFalse(_coordinate_axis_direction([1.0, 0.0, 0.5])) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0