处理一些冲突

This commit is contained in:
2026-09-07 19:40:10 +08:00
63 changed files with 5157 additions and 108 deletions
+42 -10
View File
@@ -66,9 +66,15 @@ class Build123dGeometryAdapter:
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
built.append(Edge.make_spline(points, periodic=False))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
@@ -134,20 +140,38 @@ class Build123dGeometryAdapter:
result: list[Face] = []
for region in regions:
outer = region.get("outer") or []
if len(outer) < 2:
# 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。
if len(outer) < 1:
continue
face = Face(self._wire(outer))
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1]
result.append(face.make_holes(holes) if holes else face)
return result
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
if len(edges) >= 1:
return [Face(self._wire(edges))]
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = []
for index, sketch in enumerate(sketches):
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
# 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系
# 尚未由 CDSL 表达,必须显式拒绝而非猜测。
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
if len(wires) < 2:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -189,14 +213,22 @@ class Build123dGeometryAdapter:
"""
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
hits = [
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
for point in Build123dGeometryAdapter.profile_sample_points(face)
]
distances = [value for value in hits if value is not None]
if not distances:
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
for candidate in targets:
hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points]
distances = [value for value in hits if value is not None]
if distances:
candidates.append((len(distances), min(distances), candidate, distances))
if not candidates:
raise ValueError("extent target is not reached by the profile")
# 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
+83 -16
View File
@@ -22,21 +22,22 @@ _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_through", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_cut_through",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer",
})
@@ -148,27 +149,32 @@ def _schema_contract() -> dict[str, dict[str, Any]]:
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
"""Return only sketches consumed by a feature with a sketch contract.
"""Return only sketches consumed by executable feature contracts.
CAD documents commonly preserve construction or abandoned sketches whose
contours are incomplete. They are semantic data, but must not make an
otherwise independent feature history ineligible for execution.
otherwise independent feature history ineligible for execution. Most
operations declare ``sketch_id``; loft declares its ordered section set
in ``params.profile_sketch_ids``.
"""
contracts = _schema_contract()
return frozenset(
str(feature["sketch_id"])
for feature in cdsl.get("features") or ()
if feature.get("sketch_id") is not None
and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch")
)
required: set[str] = set()
for feature in cdsl.get("features") or ():
atomic_id = str(feature.get("atomic_id") or "")
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"]))
if atomic_id == "loft_add":
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id))
return frozenset(required)
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)):
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
if len(sketch.get("contour_edges_mm") or []) >= 2:
if len(sketch.get("contour_edges_mm") or []) >= 1:
return True
return any(
entity.get("type") == "circle" and not entity.get("construction")
@@ -178,6 +184,18 @@ def _has_closed_region(sketch: dict[str, Any]) -> bool:
)
def _has_single_loft_region(sketch: dict[str, Any]) -> bool:
"""Whether a resolved profile maps exactly to one solid loft section."""
regions = sketch.get("contour_regions_mm") or []
if regions:
return len(regions) == 1 and not (regions[0].get("holes") or [])
circles = [
entity for entity in sketch.get("entities") or []
if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction")
]
return len(circles) == 1
@dataclass(frozen=True)
class CapabilityAnalysis:
plan: tuple[FeaturePlanNode, ...]
@@ -295,6 +313,54 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id,
atomic_id=node.atomic_id,
))
if node.atomic_id == "loft_add":
profile_ids = params.get("profile_sketch_ids")
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft requires at least two profile sketch ids",
))
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft profile sketch ids must be distinct",
))
else:
for sketch_id in profile_ids:
sketch_id = str(sketch_id)
profile = sketches.get(sketch_id)
if profile is None:
blockers.append(self._blocker(
node.feature_id, "missing_loft_profile",
"Loft profile sketch does not exist", sketch_id=sketch_id,
))
continue
profile_type = str((profile.get("profile") or {}).get("type") or "")
required.append(f"loft_profile:{profile_type}")
resolution_error = sketch_errors.get(sketch_id)
if resolution_error:
blockers.append(self._blocker(
node.feature_id, "profile_resolution_failed",
"A loft profile sketch could not be resolved into executable regions",
sketch_id=sketch_id, reason=resolution_error,
))
elif profile_type not in self.profile_types:
blockers.append(self._blocker(
node.feature_id, "unsupported_profile",
"The current runtime cannot resolve a loft profile",
sketch_id=sketch_id, profile_type=profile_type,
))
elif not _has_closed_region(profile):
blockers.append(self._blocker(
node.feature_id, "profile_no_closed_region",
"A loft profile contains no closed region", sketch_id=sketch_id,
))
elif not _has_single_loft_region(profile):
blockers.append(self._blocker(
node.feature_id, "unsupported_loft_profile_regions",
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
@@ -332,7 +398,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id == "extrude_add_two_sided":
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_type = reverse_condition.get("type")
required.append(f"extent:reverse:{reverse_type}")
@@ -463,7 +529,8 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "bend_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
+21 -3
View File
@@ -108,6 +108,20 @@
"required": ["end_condition"],
"additionalProperties": false
},
"loftParams": {
"type": "object",
"properties": {
"profile_sketch_ids": {
"type": "array",
"minItems": 2,
"maxItems": 16,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
}
},
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"revolveParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
@@ -367,18 +381,20 @@
"analyticSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle"]},
"type": {"enum": ["line", "arc", "circle", "bspline"]},
"start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"},
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
"clockwise": {"type": "boolean"}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
],
"additionalProperties": false
},
@@ -421,7 +437,7 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_through", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "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": {
@@ -442,6 +458,8 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"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"}}}},
+4 -4
View File
@@ -18,7 +18,7 @@ from .sketch_solver import resolve_required_sketches
P3_ATOMIC_IDS = frozenset({
"reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided",
"extrude_cut_blind", "revolve_add", "revolve_cut",
"extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
})
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
@@ -27,7 +27,7 @@ P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
# revolve history. Whether a first cut has a preceding active body remains a
# runtime preflight question, not a reason to erase it from the input pool.
_P3_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
})
@@ -47,8 +47,8 @@ def _p3_profile_ready(cdsl: dict[str, Any]) -> bool:
if profile.get("type") not in P3_PROFILE_TYPES:
return False
if not (
any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
or len(sketch.get("contour_edges_mm") or ()) >= 2
any(len(region.get("outer") or []) >= 1 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
or len(sketch.get("contour_edges_mm") or ()) >= 1
or any(
entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0
for entity in sketch.get("entities") or ()
@@ -1,15 +1,17 @@
{
"schema": "cdsl.engine.schema.v1",
"schema_version": "1.3.1",
"schema_version": "1.3.2",
"cdsl_json_schema_file": "cdsl_schema.json",
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_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":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"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":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_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":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_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":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"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","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_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":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"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},"reverse":{"type":"boolean"}},"required":["angle_deg","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":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"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},"reverse":{"type":"boolean"}},"required":["angle_deg","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","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"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"]},
@@ -72,7 +74,7 @@
"constraints": ["vertices contains at least three [u, v] points"]
},
"analytic_contours": {
"summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry."
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry."
}
}
}
+83 -6
View File
@@ -20,8 +20,8 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_cut_through",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
@@ -83,6 +83,7 @@ class GeometryAdapter(Protocol):
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
@@ -238,7 +239,7 @@ def _targeted_extent_vector(
except ValueError as error:
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition == "up_to_surface":
if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
@@ -246,6 +247,7 @@ def _targeted_extent_vector(
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
# "not reached",保持显式拒绝。
# through_next 从当前主体中选取实际命中的下一张面;
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
@@ -273,7 +275,7 @@ def _side_extent_vectors(
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` calls this once for each independently captured
``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured
termination. The regular one-sided executor also uses it for all simple
termination modes, keeping the geometry adapter interface uniform.
"""
@@ -321,7 +323,7 @@ def _extent_vectors(
end_condition = params.get("end_condition") or {"type": "blind"}
condition = end_condition.get("type", "blind")
distance = abs(float(params.get("distance_mm") or 0.0))
if node.atomic_id == "extrude_add_two_sided":
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
if reverse_distance <= 0:
@@ -459,6 +461,21 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
return session.result(node)
def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析
# 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
sketch = session.sketches.get(str(sketch_id))
if sketch is None:
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
profiles.append(sketch)
solid = session.adapter.loft(profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
@@ -782,6 +799,11 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)]
if "points_mm" in value:
value["points_mm"] = [
[float(point[index]) + components[index] for index in range(3)]
for point in value["points_mm"]
]
for child in value.values():
translate(child)
elif isinstance(value, list):
@@ -791,6 +813,30 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
return output
def _transformed_loft_profiles(
node: FeaturePlanNode,
params: dict[str, Any],
instance_id: str,
session: ExecutionSession,
transform: Callable[[dict[str, Any]], dict[str, Any]],
) -> None:
"""为 pattern replay 创建放样截面的变换副本。"""
if node.atomic_id != "loft_add":
return
profile_ids = params.get("profile_sketch_ids") or []
transformed_ids: list[str] = []
for index, sketch_id in enumerate(profile_ids):
source = session.sketches.get(str(sketch_id))
if source is None:
raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition")
transformed_id = f"{instance_id}.profile.{index}"
# 不复用原 profile:pattern 中的每个截面都必须与 source feature
# 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。
session.sketches[transformed_id] = transform(source)
transformed_ids.append(transformed_id)
params["profile_sketch_ids"] = transformed_ids
def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None:
"""解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。
@@ -831,6 +877,10 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s
# box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心,
# 否则阵列副本会静默重合在原位置。
params["center_mm"] = [float(center[index]) + components[index] for index in range(3)]
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _translated_sketch(sketch, offset),
)
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移
@@ -938,6 +988,12 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
point = value.get(point_key)
if isinstance(point, list) and len(point) == 2:
value[point_key] = [float(point[0]), -float(point[1])]
if isinstance(value.get("points"), list):
value["points"] = [
[float(point[0]), -float(point[1])]
for point in value["points"]
if isinstance(point, list) and len(point) == 2
]
for child in value.values():
mirror_local_coordinates(child)
elif isinstance(value, list):
@@ -954,6 +1010,8 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = _reflect_point(value[point_key], plane)
if "points_mm" in value:
value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]]
if value.get("normal"):
value["normal"] = _reflect_point(value["normal"], plane, vector=True)
for child in value.values():
@@ -1013,6 +1071,10 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern
# 中已被显式拒绝)。
params["center_mm"] = _reflect_point(center, plane)
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _mirrored_sketch(sketch, plane),
)
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用
@@ -1121,7 +1183,7 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
# 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转;
# 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与
# _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center
# 世界坐标点绕轴旋转。
# 世界坐标点和圆弧法向绕轴旋转。
output = deepcopy(sketch)
workplane = output.get("workplane") or {}
if workplane.get("origin_mm"):
@@ -1136,6 +1198,10 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
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)
if "points_mm" in value:
value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]]
if "normal" in value:
value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad))
for child in value.values():
rotate(child)
elif isinstance(value, list):
@@ -1187,6 +1253,10 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
center = params.get("center_mm")
if isinstance(center, list) and len(center) == 3:
params["center_mm"] = _rotated_point(center, axis, angle_rad)
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _rotated_sketch(sketch, axis, angle_rad),
)
if node.atomic_id in {"pattern_mirror", "pattern_circular"}:
# pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套
# pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。
@@ -1300,6 +1370,11 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _shape_from_primary(node, session, sketch=sketch)
def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add(node, session)
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_reference_plane(node, session)
@@ -1367,7 +1442,9 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"extrude_add_blind": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor,
"loft_add": _loft_executor,
"revolve_add": _primary_executor,
"revolve_cut": _primary_executor,
"hole_blind": _hole_executor,
+23 -4
View File
@@ -1,9 +1,9 @@
"""Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge
polygons, and closed analytic line/arc/circle contours. Semantic shapes and
historical profile macros belong to the importer compatibility layer and must
be lowered before this module is invoked.
polygons, and closed analytic line/arc/circle/B-spline contours. Semantic
shapes and historical profile macros belong to the importer compatibility
layer and must be lowered before this module is invoked.
"""
from __future__ import annotations
@@ -81,6 +81,11 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "bspline":
output["points_mm"] = [
_to_3d(workplane, point[0], point[1])
for point in edge["points_mm"]
]
transformed.append(output)
return transformed
@@ -118,6 +123,8 @@ def _reverse(edge: _Ctx) -> _Ctx:
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
if output.get("type") == "arc" and "clockwise" in output:
output["clockwise"] = not bool(output["clockwise"])
if output.get("type") == "bspline":
output["points_mm"] = list(reversed(output["points_mm"]))
return output
@@ -176,7 +183,16 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
if kind == "circle":
return _circle_edges(segment)
if kind == "bspline":
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
converted = [_point(point) for point in points]
return [{
"type": "bspline",
"start_mm": converted[0],
"end_mm": converted[-1],
"points_mm": converted,
}]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
@@ -185,6 +201,9 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
continue
if edge.get("type") != "arc":
continue
center, end = edge["center_mm"], edge["end_mm"]