diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 235836e3..512be910 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -21,8 +21,9 @@ from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec +from .parametric_bend import build_bend_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, BendSpec, HoleSpec, PlaneSpec, ThreadSpec, TopologyRecord, Vector3, canonical_plane_signature def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -700,6 +701,26 @@ class Build123dGeometryAdapter: plane = Plane(origin=_vector(spec.axis.origin_mm), x_dir=frame_x, z_dir=direction) return solid.moved(Location(plane)) + @staticmethod + def bend_solid(spec: BendSpec) -> Any: + """Build one bent sheet segment placed on ``spec.frame``. + + The parametric generator constructs the sheet locally with the first + wing along +X, its mid-plane spanning +X/+Z (thickness along +Y) and + the fold (width) axis along +Z. This gate rotates that local frame so + the first wing lands on ``spec.frame.y_dir`` (= normal x x_dir), the + sheet thickness on ``spec.frame.normal`` and the width axis on + ``spec.frame.x_dir``. + """ + solid = build_bend_solid(spec) + frame = spec.frame + plane = Plane( + origin=_vector(frame.origin_mm), + x_dir=_vector(frame.y_dir), + z_dir=_vector(frame.x_dir), + ) + return solid.moved(Location(plane)) + @staticmethod def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any: # 对指定边以给定半径做圆角。 diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index c5a9f34e..c0a94d4a 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -39,15 +39,15 @@ _BODY_MUTATING_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", - "thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies", + "thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies", }) # 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 -# and thread_cut are excluded: pattern translation does not yet move their -# parametric axis, so a replayed thread would silently re-run at the original -# location. +# Context-only features have no geometry definition to instance. thread_add, +# thread_cut and bend_add are excluded: pattern translation does not yet move +# their parametric axis/frame, so a replayed instance would silently re-run at +# the original location. _REPLAYABLE_ATOMICS = ( - _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "shell"}) + _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "shell"}) ) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", @@ -449,7 +449,7 @@ class CapabilityAnalyzer: required.append(f"extent:{end_type}") if end_type not in _SUPPORTED_EXTENTS: blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type)) - target_kind = _EXTENT_TARGET_KINDS.get(end_type) + target_kind = _EXTENT_TARGET_KINDS.get(end_type or "") if target_kind: required.append(f"selector:extent_target:{target_kind}") reference = end_condition.get("reference") @@ -478,7 +478,7 @@ class CapabilityAnalyzer: node.feature_id, "unsupported_reverse_extent", "The reverse extent is not implemented", extent=reverse_type, )) - reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type) + reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type or "") if reverse_target_kind: required.append(f"selector:reverse_extent_target:{reverse_target_kind}") reverse_reference = reverse_condition.get("reference") @@ -610,7 +610,7 @@ class CapabilityAnalyzer: "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", - "thread_add", + "thread_add", "bend_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 cfb8fc8a..c72dd4a7 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -210,6 +210,38 @@ "required": ["radius_mm", "height_mm"], "additionalProperties": false }, + "bendChainLeg": { + "type": "object", + "properties": { + "leg_mm": {"$ref": "#/$defs/positive"}, + "bend_angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180}, + "inner_radius_mm": {"type": "number", "minimum": 0}, + "side": {"type": "integer", "enum": [1, -1]} + }, + "required": ["leg_mm"], + "additionalProperties": false + }, + "bendAddParams": { + "type": "object", + "properties": { + "thickness_mm": {"$ref": "#/$defs/positive"}, + "width_mm": {"$ref": "#/$defs/positive"}, + "chain": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/bendChainLeg"}}, + "frame": { + "type": "object", + "properties": { + "origin_mm": {"$ref": "#/$defs/point3"}, + "x_dir": {"$ref": "#/$defs/point3"}, + "y_dir": {"$ref": "#/$defs/point3"}, + "normal": {"$ref": "#/$defs/point3"} + }, + "required": ["origin_mm", "x_dir", "y_dir", "normal"], + "additionalProperties": false + } + }, + "required": ["thickness_mm", "width_mm", "chain"], + "additionalProperties": false + }, "threadAddParams": { "type": "object", "properties": { @@ -490,7 +522,7 @@ "required": ["type", "contours"], "additionalProperties": false }, - "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, + "feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "shell", "boolean_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]}, "feature": { "type": "object", "properties": { @@ -523,6 +555,7 @@ {"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": "bend_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/bendAddParams"}}}}, {"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"}}}}, diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 3e270881..4f0f6ce5 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -22,6 +22,7 @@ "hole_blind": {"atomic_id":"hole_blind","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}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"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","through_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"]}, + "bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"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"]}, diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index a7e8d9ab..cbe258da 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Protocol from .build123d_adapter import Build123dGeometryAdapter from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract from .runtime_types import ( - AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, + AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, ThreadSpec, Vector3, RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, @@ -28,6 +28,7 @@ ALL_ATOMIC_IDS = frozenset({ "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", "pattern_circular", "boolean_bodies", "thread_add", "thread_cut", + "bend_add", }) @@ -102,6 +103,7 @@ class GeometryAdapter(Protocol): def cut(self, body: Any, tool: Any) -> Any: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... + def bend_solid(self, spec: BendSpec) -> Any: ... def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ... def body_center(self, body: Any) -> Vector3: ... def body_span(self, body: Any, direction: Vector3) -> float: ... @@ -926,6 +928,23 @@ def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> Fea return session.result(node) +def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: + # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 + # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 + spec = BendSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 + solid = session.adapter.bend_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: + # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_bend(node, session) + + def _host_plane(resolution: SelectorResolution) -> PlaneSpec: if resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") @@ -1905,6 +1924,7 @@ EXECUTORS: dict[str, ExecutorFunction] = { "cylinder_add": _cylinder_executor, "thread_add": _thread_executor, "thread_cut": _thread_executor, + "bend_add": _bend_executor, "extrude_add_blind": _primary_executor, "extrude_add_blind_with_hole": _primary_executor, "extrude_add_two_sided": _primary_executor, diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index d73b705a..944fa9dc 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -355,6 +355,111 @@ class ThreadSpec: ) +@dataclass(frozen=True) +class BendLeg: + """One straight wing of a bent sheet-metal chain (runtime-neutral). + + ``leg_mm`` is the straight mid-plane distance from the previous fold vertex + to the fold vertex leaving this wing (the unfolded chord length between the + two surrounding fold vertices). When the wing has a following wing, + ``bend_angle_deg`` is the required *interior* angle between the two wings + (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner + bend-surface fillet radius (>= 0; the outer radius is always + ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction + (+1 / -1). A trailing wing carries no fold; stray fold fields on the last + wing are ignored for tolerance. + """ + + leg_mm: float + bend_angle_deg: float | None = None + inner_radius_mm: float = 0.0 + side: int = 1 + + +@dataclass(frozen=True) +class BendSpec: + """Runtime-neutral definition of a sheet-metal bend (``bend_add``). + + The part is described by its mid-plane centreline: ``chain`` lists the + straight wings joined by equal-thickness bend corners. ``thickness_mm`` + and ``width_mm`` are the sheet thickness and the full length along the fold + (width) axis. ``frame.origin_mm`` anchors the start of the first wing's + mid-plane path, ``frame.x_dir`` is the fold/width axis and + ``frame.normal`` is the mid-plane normal of the first wing; the first wing + extends along ``normal x x_dir``. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a bent solid keeping source-contract parsing separate + from B-rep work (same pattern as :class:`ThreadSpec`). + """ + + thickness_mm: float + width_mm: float + frame: PlaneSpec + chain: tuple[BendLeg, ...] + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "BendSpec": + try: + thickness = float(params.get("thickness_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("bend thickness/width must be numeric") from error + if thickness <= 0 or width <= 0: + raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") + raw_chain = params.get("chain") + if not isinstance(raw_chain, (list, tuple)) or not raw_chain: + raise ValueError("bend requires a non-empty chain of wing segments") + chain: list[BendLeg] = [] + for index, raw_leg in enumerate(raw_chain): + if not isinstance(raw_leg, dict): + raise ValueError(f"bend chain[{index}] must be an object with leg_mm") + try: + leg = float(raw_leg.get("leg_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error + if leg <= 0: + raise ValueError(f"bend chain[{index}].leg_mm must be positive") + bend_angle: float | None = None + if index < len(raw_chain) - 1: + raw_angle = raw_leg.get("bend_angle_deg") + if raw_angle is None: + raise ValueError( + f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" + ) + try: + bend_angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error + if not 0 < bend_angle < 180: + raise ValueError("bend interior angle must be between 0 and 180 degrees") + radius = float(raw_leg.get("inner_radius_mm") or 0.0) + raw_side = raw_leg.get("side") + side = 1 if raw_side is None else int(raw_side) + if radius < 0: + raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") + if side not in (1, -1): + raise ValueError(f"bend chain[{index}].side must be +1 or -1") + chain.append(BendLeg( + leg_mm=leg, + bend_angle_deg=bend_angle, + inner_radius_mm=radius, + side=side, + )) + raw_frame = params.get("frame") + if isinstance(raw_frame, dict): + frame = PlaneSpec.from_mapping(raw_frame) + else: + # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 + frame = PlaneSpec( + origin_mm=(0.0, 0.0, 0.0), + x_dir=(0.0, 0.0, 1.0), + y_dir=(1.0, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) + + @dataclass(frozen=True) class RuntimeDiagnostic: code: str diff --git a/json_to_cdsl/output/corpus-manifest.json b/json_to_cdsl/output/corpus-manifest.json index 27917825..f729d154 100644 --- a/json_to_cdsl/output/corpus-manifest.json +++ b/json_to_cdsl/output/corpus-manifest.json @@ -1,20 +1,20 @@ { "schema": "cdsl.corpus-manifest.v1", - "corpus_version": "2026-08-31", + "corpus_version": "2026-09-07", "document_count": 2881, - "document_stems_sha256": "ba5b905191817cd0f7b9ad07d4002ad27730e200a395f71f9c4e31ebb1867cd6", + "document_stems_sha256": "4c8660ed96f3b5427ad26adadd9b82df447951913920542501ef0fc479c02773", "phase_pools": { "p3": { "selector_count": 771, - "selectors_sha256": "72093701357cd1858f2dbcc0f1ab9e6c7baea7db26357154e0586e5e1be6f801" + "selectors_sha256": "2c0f76833cd08592b71ef3a5b5b1548b649ac485e2b2388358426f815c21a047" }, "p4": { "selector_count": 826, - "selectors_sha256": "ab9ea6edd8532ce7a0110d0725b5303c63a53617a0af502101b12f657285d371" + "selectors_sha256": "c031552f9ae29f03ca58545d90dc8b16e2b5963f0923ff075e3724f066a2bf3d" }, "p6": { "selector_count": 893, - "selectors_sha256": "ac9f91b690bc533b1fdd1772d65ee77b248d108f39a429615d909b78332d9a08" + "selectors_sha256": "de44a06e55aa21940e9ccb02d0deaba90bb7669d8fb93a62bf976c903a18c42f" } } }