Merge pull request '修改了一些之前增加功能的bug' (#11) from ganjihong into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-09-07 15:57:53 +08:00
7 changed files with 564 additions and 24 deletions
+48 -10
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import math import math
from typing import Any, Iterable 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 .parametric_thread import build_thread_solid
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature
@@ -163,8 +163,11 @@ class Build123dGeometryAdapter:
return None return None
if len(members) == 1: if len(members) == 1:
return members[0] return members[0]
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。 # build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域
return Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue] # 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在,
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明
# make_composite,保留宽泛类型桩噪音抑制。
return Compound.make_composite(members)
@staticmethod @staticmethod
def extrude(face: Face, direction: Vector3) -> Solid: 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) return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
@staticmethod @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 原生扫掠,路径可为直线/曲线/螺旋边)。 # 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。
return Solid.sweep(section, spine) # 默认值对齐 build123d Solid.sweepmake_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 @staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire: 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)]) return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)])
@staticmethod @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),供扫掠/后续螺纹、斜齿等特征使用。 # 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。
# 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。 # 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。
# turns(圈数)与 height_mm(轴向总高)二选一驱动:按圈适配斜齿/花键,
# 按高度适配 parametric_threadlength + 两端余量)。构造与 parametric_thread
# 原 Edge.make_helix 同源,保证生成器复用后逐位一致。
if pitch_mm <= 0: if pitch_mm <= 0:
raise ValueError("helix pitch must be positive") raise ValueError("helix pitch must be positive")
if turns <= 0: if (turns is None) == (height_mm is None):
raise ValueError("helix turns must be positive") raise ValueError("helix_path needs exactly one of turns or height_mm")
helix = Helix(pitch=pitch_mm, height=turns * pitch_mm, radius=radius_mm, lefthand=lefthand) if turns is not None:
return helix.edges()[0] 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 @staticmethod
def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any: def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any:
+18 -5
View File
@@ -21,7 +21,7 @@ _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
_PRIMARY_ATOMICS = frozenset({ _PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "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"}) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({ _ACTIVE_BODY_REQUIRED = frozenset({
@@ -32,8 +32,8 @@ _ACTIVE_BODY_REQUIRED = frozenset({
}) })
_BODY_MUTATING_ATOMICS = frozenset({ _BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "sphere_add", "thread_add", "thread_cut", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
*_HOLE_ATOMICS, "fillet", "chamfer", "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
}) })
# A pattern may replay a previous pattern as well as a direct body mutation. # 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 # Context-only features have no geometry definition to instance. thread_add
@@ -42,7 +42,7 @@ _BODY_MUTATING_ATOMICS = frozenset({
# location. # location.
_REPLAYABLE_ATOMICS = ( _REPLAYABLE_ATOMICS = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"}) _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut"})
) | frozenset({"pattern_linear", "pattern_mirror"}) ) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({ _SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "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", "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"): 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")) 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") 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))) results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
if status == "executable": if status == "executable":
@@ -436,7 +448,8 @@ class CapabilityAnalyzer:
body_available = True body_available = True
body_producers = { body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "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 一致:无宿主时由 # thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。 # active_body 前置阻止,文档含该类特征即视为携带可执行几何。
"thread_cut", "thread_cut",
+36 -1
View File
@@ -117,6 +117,27 @@
"required": ["radius_mm", "center_mm"], "required": ["radius_mm", "center_mm"],
"additionalProperties": false "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": { "threadAddParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -242,6 +263,17 @@
"required": ["source_feature_ids", "mirror_plane"], "required": ["source_feature_ids", "mirror_plane"],
"additionalProperties": false "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": { "referencePlaneParams": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -351,7 +383,7 @@
"required": ["type", "contours"], "required": ["type", "contours"],
"additionalProperties": false "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": { "feature": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -374,6 +406,8 @@
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}}, {"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": "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": "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_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": "thread_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/threadCutParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeBlindParams"}}}}, {"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": "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_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_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_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": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
@@ -110,11 +110,12 @@ def _build_z_aligned(spec: ThreadSpec) -> Solid:
# material; the crest centre sits at the helix start phase (z = 0), which # 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. # also puts the z = 0 end face through full crest material after trimming.
helix_height = spec.length_mm + 2.0 * pitch helix_height = spec.length_mm + 2.0 * pitch
helix = Edge.make_helix( # 复用 adapter 门面构造螺旋路径与扫掠。函数级延迟导入:build123d_adapter 顶部
pitch=pitch, # 已 import build_thread_solid,模块级反向 import adapter 会形成循环依赖。
height=helix_height, from .build123d_adapter import Build123dGeometryAdapter
radius=root_radius,
lefthand=spec.lefthand, 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 # 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 = 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) 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. # Core cylinder at the nominal minor radius spanning the whole helix.
# (The sunken tooth roots overlap it so the union below is clean.) # (The sunken tooth roots overlap it so the union below is clean.)
@@ -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_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"]}, "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"]}, "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_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"]}, "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":[]}, "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"]}, "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"]}, "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_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": { "profiles": {
"circle": { "circle": {
+285 -1
View File
@@ -22,8 +22,10 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({ ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "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", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
"pattern_circular",
"thread_add", "thread_cut", "thread_add", "thread_cut",
}) })
@@ -513,6 +515,56 @@ def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> Feature
return session.result(node) 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: def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
@@ -754,6 +806,11 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s
axis = params.get("axis") or {} axis = params.get("axis") or {}
if axis.get("origin_mm"): if axis.get("origin_mm"):
axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] 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") mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 # #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) axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
if axis.get("direction"): if axis.get("direction"):
axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) 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") mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 # #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) 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: def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
mirror = node.params.get("mirror_plane") or {} mirror = node.params.get("mirror_plane") or {}
resolution = session.resolve(mirror) resolution = session.resolve(mirror)
@@ -983,6 +1055,11 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
dependency = pattern_transform_blocker(source) dependency = pattern_transform_blocker(source)
if dependency: if dependency:
raise ValueError(f"mirror pattern source uses an unsupported {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) cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
sketch = session.sketches.get(str(source.sketch_id)) sketch = session.sketches.get(str(source.sketch_id))
_execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) _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) 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
# sourcepattern_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: def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult:
executor = EXECUTORS.get(node.atomic_id) executor = EXECUTORS.get(node.atomic_id)
if executor is None: if executor is None:
@@ -1024,6 +1295,16 @@ def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: d
return _execute_sphere(node, session) 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: def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch del sketch
return _execute_hole(node, session) return _execute_hole(node, session)
@@ -1058,6 +1339,8 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"reference_plane": _reference_plane_executor, "reference_plane": _reference_plane_executor,
"reference_axis": _reference_axis_executor, "reference_axis": _reference_axis_executor,
"sphere_add": _sphere_executor, "sphere_add": _sphere_executor,
"box_add": _box_executor,
"cylinder_add": _cylinder_executor,
"thread_add": _thread_executor, "thread_add": _thread_executor,
"thread_cut": _thread_executor, "thread_cut": _thread_executor,
"extrude_add_blind": _primary_executor, "extrude_add_blind": _primary_executor,
@@ -1073,6 +1356,7 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"chamfer": _chamfer_executor, "chamfer": _chamfer_executor,
"pattern_linear": _linear_pattern_executor, "pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor, "pattern_mirror": _mirror_pattern_executor,
"pattern_circular": _circular_pattern_executor,
} }
@@ -0,0 +1,166 @@
"""N2 pattern_circular —— 旋转几何契约层测试。
中文说明
--------
统计报告把 N2直线/环形阵列归为 A runtime pattern 采用源特征
重放范式pattern_linear/_translated_nodepattern_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_exactbox_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()