Compare commits

...

13 Commits

Author SHA1 Message Date
ganjihong cf9cd0366a refactor(cdsl_engine): extract topology evidence export from the adapter
Phase 6 of the decoupling refactor (behavior-preserving move):
- topology_export.py: body_geometry / surface_geometry / topology_records
  (~290 lines) moved verbatim out of build123d_adapter.py as module-level
  functions; includes the circle_center_mm/radius_mm concentric-circle
  evidence added by the lk_dev integration
- build123d_adapter.py: the three methods are now one-line forwards, so
  kernel construction and topology evidence export live in separate files
  and can evolve independently

No protocol change (GeometryAdapter untouched). Verified by:
- golden comparison: full-corpus analyze (2881 docs) + 12 complete
  rebuilds -- zero field differences vs f79c287
- full test suite: 435 tests, failure set identical to baseline
2026-09-09 16:07:25 +08:00
ganjihong 6fa4501f16 docs(cdsl_engine): document package layout and the add-atomic-operation workflow
Phase 8: README now maps every module of the split package, documents
the executor registration + schema-contract workflow for new atomic
operations, and restates the schema maintenance rule for executors/.
2026-09-09 14:34:14 +08:00
ganjihong 95cae203f4 refactor(cdsl_engine): freeze legacy engine paths under legacy/
Phase 7 of the decoupling refactor (behavior-preserving move):
- legacy/llm_compiler.py, legacy/llm_engine.py: frozen build_pack path
  (unused by run_cdsl_only), moved with git mv for history
- legacy/exact_rebuild.py: _run_parameterized / _run_exact /
  _apply_geometric_compensations moved out of rebuild.py, including
  the part-specific compensation table
- rebuild.py keeps run_rebuild / run_cdsl_only / compare_with_gold and
  imports the moved functions
- llm_compiler.py / llm_engine.py become compatibility shims

Frozen zone: new capability belongs in executors/ + schema contracts.
The project-specific compensation no longer sits in the main pipeline.
2026-09-09 14:02:53 +08:00
ganjihong ad88d92ab9 refactor(cdsl_engine): split translator.py into the translator package
Phase 5 of the decoupling refactor (behavior-preserving move):
- translator/ir.py: SolidWorks plugin JSON to backend-IR conversion (70 syms)
- translator/codegen.py: backend IR to build123d source generation (64 syms)
- translator/runtime_lib.py: frozen generated-script runtime library,
  spliced into generate_build123d_code as *RUNTIME_LIB_LINES
- translator/common.py: helpers shared by both sides
- translator/__init__.py: full historical symbol surface re-exported

Generated-code equivalence verified byte-for-byte against the pre-split
output for a representative IR sample; py_compile clean.
2026-09-09 13:56:08 +08:00
ganjihong beac59fc8b refactor(cdsl_engine): sink atomic runtime capability flags into schema contracts
Phase 4 of the decoupling refactor (behavior-preserving):
- profile_schema.json: every operation contract now carries
  runtime_capability {body_mutating, requires_active_body,
  replayable, requires_selector, open_profile_ok} (single source of truth)
- operation_contracts.py: validates and forwards the flags
- capabilities.py: the five data-classification frozensets are now
  derived from the schema at import time; dispatch-logic sets
  (_HOLE_ATOMICS, _PATTERN_ATOMICS, extent constants) stay in code
- test_profile_schema.py: completeness + structural invariants test

Equivalence proven by flag counts (27/13/25/6/2) matching the previous
hand-written sets and by the unchanged test-baseline failure set.
2026-09-09 13:41:38 +08:00
ganjihong 5ffb106f36 refactor(cdsl_engine): executor registry + per-family executor package
Phase 3 of the decoupling refactor (behavior-preserving):
- registry.py: atomic_executor decorator, ALL_ATOMIC_IDS with
  fail-fast registration validation, execute_node dispatcher
- executors/: one module per family (extrude, revolve, surfaces,
  loft_sweep, bodies, context, primitives, parametric, holes,
  dressup, patterns) + shared helpers in executors/common
- executors/__init__: explicit aggregation + completeness check
  (registry must cover every declared atomic id at import time)
- runtime.py: slimmed to entry points (analyze_cdsl/rebuild_cdsl)
  plus full historical re-exports incl. test-referenced privates

Adding an atomic operation now touches only one executor module and
its schema contract; the shared registry never changes. Verified
against baseline: zero new failures.
2026-09-09 13:33:06 +08:00
ganjihong 871070c440 refactor(cdsl_engine): extract session/extents/pattern_transform from runtime
Phase 2 of the decoupling refactor (behavior-preserving move):
- runtime_base.py: RuntimeExecutionError, FeatureExecutionError, ExtentVector
- session.py: GeometryAdapter protocol + ExecutionSession
- extents.py: end-condition planning (_extent_vectors family)
- pattern_transform.py: translate/mirror/rotate replay parameter algebra
- runtime.py: keeps executors + registry + entry points; re-exports all
  moved names (incl. test-referenced privates) for import stability

No behavior change; verified against baseline (zero new failures).
2026-09-09 13:13:17 +08:00
ganjihong 3fb08423da refactor(cdsl_engine): split runtime_types into specs + topology with shim
Phase 1 of the decoupling refactor (behavior-preserving move):
- specs.py: vector math, plane/axis helpers, parametric feature specs
- topology.py: diagnostics, planning contracts, TopologyRegistry
- runtime_types.py: compatibility shim re-exporting all public names

No behavior change; all historical import paths keep working.
2026-09-09 12:58:58 +08:00
likang f79c28759d Merge pull request 'Lk dev' (#20) from lk_dev into main
Reviewed-on: #20
2026-09-09 10:41:30 +08:00
likang a5c3ec1f36 merge: resolve main integration conflicts 2026-09-09 10:39:34 +08:00
likang 2f89d7930c feat(cadfs): expand reconstruction capability coverage 2026-09-09 10:11:46 +08:00
likang 9ab20db443 Merge pull request '几个几何操作的微小bug' (#18) from ganjihong into main
Reviewed-on: #18
2026-09-08 20:01:55 +08:00
likang cf991dd619 Merge pull request 'N5 渐开线齿廓与齿条专用生成 involute_gear 的功能增加' (#17) from ganjihong into main
Reviewed-on: #17
2026-09-08 14:25:26 +08:00
62 changed files with 19033 additions and 10176 deletions
@@ -1 +1 @@
只使用当前工具 schema、`operation_contract` 和服务端提供的 topology/reference token;它们高于本指引、示例和任何经验。每次只完成被调度的一个原子操作,不编造 atom、字段、selector 或能力。参数须为有限 mm/deg 数值。已有可执行 checkpoint 是应保留的最佳结果;能力缺口、未验证项和视觉疑点要如实交给服务端证据流程,不能用虚构几何掩盖。
以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。
@@ -1 +1 @@
世界坐标默认是右手 mm。根 `XY` 与 `+Z` 挤出只用于当前 contract 允许的根挤出。原点按功能基准选取:对称件取中心,板件取占地中心,轴对称件取轴线,存在安装或配合接口时取安装面、配合轴或明确接口基准。`workplane.origin_mm` 是局部 `(0,0)` 的世界点,`x_dir` 是局部 `+X`,`normal` 指向正挤出;草图局部坐标不是世界坐标。孔 `positions[].mm` 是宿主面上的绝对世界点,不是面内偏移。非根特征依据当前 contract、测量宿主和 token 建立 frame,不能猜测最后生成面或全局平面。`reference_plane`/`reference_axis` 是唯一支持的命名定位上下文;先创建基准,再创建依赖它的旋转、镜像、阵列和草图。位置必须由基准、中心线、偏移、节距或半径导出;定位失败时改 frame、偏移或方向,不修改已发布工件。
世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。
@@ -1,6 +1,6 @@
{
"schema_version": "cdsl.author-guidance.manifest.v1",
"version": "2026-09-03.1",
"version": "2026-09-09.1",
"sections": [
{"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true},
{"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false},
@@ -42,13 +42,19 @@
"final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"],
"operation_sections": {
"extrude_add_blind": ["op-extrude-add"],
"extrude_add_blind_with_hole": ["op-extrude-add"],
"extrude_add_two_sided": ["op-extrude-add"],
"extrude_from_face": ["op-extrude-add"],
"extrude_surface": ["op-extrude-add"],
"extrude_cut_blind": ["op-extrude-cut"],
"extrude_cut_two_sided": ["op-extrude-cut"],
"extrude_cut_through": ["op-extrude-cut"],
"loft_add": ["op-loft"],
"loft_add_with_cap_face": ["op-loft"],
"revolve_add": ["op-revolve"],
"revolve_cut": ["op-revolve"],
"revolve_surface": ["op-revolve"],
"sweep_add": ["op-loft"],
"hole_blind": ["op-hole"],
"hole_counterbore": ["op-hole"],
"hole_countersink": ["op-hole"],
@@ -60,6 +66,10 @@
"pattern_circular": ["op-pattern"],
"fillet": ["op-finish"],
"chamfer": ["op-finish"],
"shell": ["op-finish"],
"boolean_bodies": ["op-finish"],
"transform_bodies": ["op-finish"],
"delete_bodies": ["op-finish"],
"sphere_add": ["op-sphere"],
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
@@ -1 +1 @@
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。先完成影响这些边的布尔、孔和阵列,再按 contract 使用正半径或距离。局部材料不足、相邻过渡相交或 token 已失效时,不用全局边选择兜底;保留主体并报告该收尾特征的风险。
`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。
@@ -1 +1 @@
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。
+49 -12
View File
@@ -4,17 +4,53 @@ This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path:
`semantic validation -> capability analysis -> sketch resolution -> session runtime -> STEP`
`runtime.py` owns the executor registry, an `ExecutionSession`, and the
feature/topology lifecycle. `build123d_adapter.py` is the only layer that
creates or mutates B-rep objects. `runtime_types.py` owns runtime-neutral
feature, context, selector, and topology contracts. `llm_compiler.py` and
`llm_engine.py` remain available for legacy engine-plan compatibility but are
not used by `run_cdsl_only`.
## Package layout
| Module | Responsibility |
|---|---|
| `specs.py` | Vector math, plane/axis helpers, parametric feature specs (no kernel deps) |
| `topology.py` | Diagnostics, planning contracts, `TopologyRegistry`, selector resolution |
| `capabilities.py` | `CapabilityAnalyzer`: preflight blockers before any geometry runs |
| `sketch_solver.py` | Profile expansion (circle / polygon / analytic contours) |
| `session.py` | `ExecutionSession` and the kernel-facing `GeometryAdapter` protocol |
| `runtime_base.py` | Shared error types and the `ExtentVector` value |
| `extents.py` | End-condition planning (blind / through / up-to-surface / ...) |
| `pattern_transform.py` | Translate/mirror/rotate parameter algebra for pattern replay |
| `registry.py` | `EXECUTORS`, the `atomic_executor` decorator, and `execute_node` dispatch |
| `executors/` | One module per executor family; importing the package registers all |
| `build123d_adapter.py` | The only layer that creates or mutates B-rep objects |
| `topology.py` / `runtime_types.py` | Historical re-export shim (`runtime_types`) |
| `translator/` | Frozen SolidWorks-exact code generation (`ir` / `codegen` / `runtime_lib`) |
| `legacy/` | Frozen legacy engine paths (`llm_compiler`, `llm_engine`, exact rebuilds) |
| `rebuild.py` | Legacy three-way facade plus `compare_with_gold` acceptance |
`runtime.py` keeps the `analyze_cdsl` / `rebuild_cdsl` entry points and
re-exports the historical names. `llm_compiler.py` and `llm_engine.py` are
compatibility shims for the frozen `legacy/` implementations and are not used
by `run_cdsl_only`.
## Adding an atomic operation
1. Add the operation contract to `profile_schema.json` (`operation_contracts`),
including its `runtime_capability` flags — this is the single source of
truth for preflight classification.
2. Add a decorated executor function in exactly one `executors/<family>.py`
module (`@atomic_executor("...")`); the shared registry never changes.
3. Add the atomic id to `ALL_ATOMIC_IDS` in `registry.py` (registration fails
fast on unknown or duplicate ids, and `executors/__init__` fails if any
declared id has no registered executor).
4. Update `cdsl_schema.json` in the same change.
5. Extend `backend/tests/test_profile_schema.py` fixtures if the contract
shape changed.
Multiple people can add different operations in parallel without touching a
shared registry file: the only shared edits are the two schema documents.
Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`.
Supported feature atomic operations are defined by `EXECUTORS` in `runtime.py`.
Their human-readable contract is in `profile_schema.json`; the complete,
machine-enforced CDSL object contract is in `cdsl_schema.json`.
Supported feature atomic operations are defined by `EXECUTORS` (populated from
`executors/` at import time). Their human-readable contract is in
`profile_schema.json`; the complete, machine-enforced CDSL object contract is
in `cdsl_schema.json`.
The Studio only accepts self-contained profile data and requires successful
`engine=cdsl_only` output. It never uses the legacy translator fallback or
`compiler_context`.
@@ -24,10 +60,11 @@ The Studio only accepts self-contained profile data and requires successful
`profile_schema.json` and `cdsl_schema.json` together are the source of truth
for the engine contract exposed to the CAD Agent and the backend validator.
Any addition, removal, rename, or parameter-contract change in
`sketch_solver.py`, `runtime.py`, or the build adapter must update both files
`sketch_solver.py`, `executors/`, or the build adapter must update both files
in the same change.
`backend/tests/test_profile_schema.py` fails when the registered profiles or
supported atomic operations diverge from the document.
`backend/tests/test_profile_schema.py` fails when the registered profiles,
supported atomic operations, or `runtime_capability` flags diverge from the
document.
## Batch baseline
File diff suppressed because it is too large Load Diff
+510 -45
View File
@@ -12,43 +12,22 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
from .runtime_types import (
CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic,
pattern_instance_member_id, transform_copy_member_id,
)
from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "revolve_surface", "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_two_sided", "extrude_cut_through",
"loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_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", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer",
})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance. thread_add,
# thread_cut, bend_add, gear_add and rack_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", "bend_add", "gear_add", "rack_add"})
) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
# ``ExecutionSession.body_members`` only contains independently selectable
# body outputs. A normal additive/cut/dress-up feature replaces the active
# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two
# contracts that preserve a previous member. Circular patterns over those
# members can additionally expose a proven COPY instance; replayed/fused
# patterns remain ineligible because no exact instance ownership exists.
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
_SUPPORTED_EXTENTS = frozenset({
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body",
@@ -61,6 +40,200 @@ _EXTENT_TARGET_KINDS = {
}
def _runtime_capabilities() -> dict[str, dict[str, bool]]:
"""Return the per-atomic runtime capability flags from the schema registry.
``profile_schema.json.operation_contracts[*].runtime_capability`` is the
single source of truth for these classifications. Adding an atomic
operation updates one JSON contract instead of editing code-level sets.
"""
path = Path(__file__).with_name("profile_schema.json")
contracts = materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
return {atomic_id: contract["runtime_capability"] for atomic_id, contract in contracts.items()}
_RUNTIME_CAPABILITIES = _runtime_capabilities()
_SELECTOR_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_selector"])
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["open_profile_ok"])
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"])
_BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["body_mutating"])
# 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,
# thread_cut, bend_add, gear_add and rack_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 = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"])
def _mappings(value: Any):
"""Yield nested feature mappings for capability-only contract checks."""
if isinstance(value, dict):
yield value
for child in value.values():
yield from _mappings(child)
elif isinstance(value, list):
for child in value:
yield from _mappings(child)
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
"""Return internal body-member keys named by a transform contract."""
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
source_ids.update(
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get("pattern_instance_refs") or ()
if isinstance(reference, dict)
)
source_ids.update(
transform_copy_member_id(
str(reference.get("transform_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
)
for reference in params.get("transform_copy_refs") or ()
if isinstance(reference, dict)
)
return source_ids
def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
"""Project structured pattern refs to internal body-member keys."""
return {
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get(parameter) or ()
if isinstance(reference, dict)
}
def _next_body_graph(
node: FeaturePlanNode,
members: set[str],
has_active_body: bool,
nodes_by_id: dict[str, FeaturePlanNode],
) -> tuple[set[str], bool]:
"""Project the explicit runtime body-member lifecycle without geometry.
The capability phase cannot know whether two B-reps intersect, but it can
mirror the ownership contract used by ``ExecutionSession``. This keeps a
historical feature's successful execution separate from its continued
availability as an independently selectable body. In particular, this
must never turn an absorbed feature or pattern replay into ``session.body``.
"""
atomic_id = node.atomic_id
feature_id = node.feature_id
if atomic_id == "boolean_bodies":
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
next_members = members - target_ids - tool_ids
next_members.add(feature_id)
if bool(node.params.get("keep_tools")):
next_members.update(tool_ids)
return next_members, True
if atomic_id == "transform_bodies":
source_ids = _transform_member_sources(node.params)
next_members = set(members)
if not bool(node.params.get("make_copy")):
next_members.difference_update(source_ids)
next_members.add(feature_id)
elif len(node.params.get("source_feature_ids") or ()) > 1:
# Multi-source COPY outputs have no aggregate body-member owner.
# Keep each transformed source addressable by its exact origin.
next_members.update(
transform_copy_member_id(feature_id, source_id)
for source_id in source_ids
)
else:
next_members.add(feature_id)
return next_members, True
if atomic_id == "delete_bodies":
source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
next_members = members - source_ids
return next_members, bool(next_members)
if atomic_id in _PATTERN_ATOMICS:
source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()}
if (
atomic_id == "pattern_mirror"
and source_ids
and source_ids <= members
and all(
(source := nodes_by_id.get(source_id)) is not None
and source.params.get("result_mode") == "new_body"
for source_id in source_ids
)
):
# A mirror produces an independently addressable COPY only when
# every source is an explicit NEW body. A hole, dress-up, or
# ordinary additive feature may be the current aggregate's
# successor, not a standalone body: its mirror must remain a
# feature replay and cannot be exposed as a body member.
next_members = set(members)
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, 1)
for source_id in source_ids
)
return next_members, True
if (
atomic_id == "pattern_circular"
and str(node.params.get("operation_mode") or "add") == "add"
and source_ids
and source_ids <= members
):
count = int(node.params.get("pattern_count") or 0)
excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()}
next_members = set(members)
for instance in range(1, count):
if instance in excluded:
continue
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, instance)
for source_id in source_ids
)
return next_members, True
# Replay/fused pattern output has no member-level contract. It creates
# active geometry, but cannot prove which replay instance a later body
# query names.
return set(), has_active_body
if atomic_id not in _BODY_MUTATING_ATOMICS:
return members, has_active_body
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
# Primary cuts execute per explicit member in the runtime so a later
# body query still addresses the same CADFS NEW/COPY lifecycle node.
return set(members), True
if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body":
return members | {feature_id}, True
# All remaining body-mutating executors register their output as the sole
# explicit member. This includes fused additive features, cuts and
# dress-ups, whose source-member topology no longer has an identity.
return {feature_id}, True
def _has_explicit_axis(axis: Any) -> bool:
return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
@@ -171,6 +344,13 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
# The exact bounded-region proof happens in the OCC splitter. At this
# stage the typed contract proves only that region selection work is
# possible; an unbounded or ambiguous runtime result remains a stable
# feature execution diagnostic rather than a guessed sketch contour.
return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections"))
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
@@ -254,6 +434,7 @@ class CapabilityAnalyzer:
results: list[CapabilityResult] = []
completed: set[str] = set()
body_available = False
body_members: set[str] = set()
for node in plan:
blockers: list[RuntimeDiagnostic] = []
contract = self.contracts.get(node.atomic_id)
@@ -267,7 +448,9 @@ class CapabilityAnalyzer:
for dependency in node.depends_on:
if dependency not in completed:
blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
if node.atomic_id in _ACTIVE_BODY_REQUIRED:
if node.atomic_id in _ACTIVE_BODY_REQUIRED or (
node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
required.append("active_body")
if not body_available:
blockers.append(self._blocker(
@@ -362,6 +545,74 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
for selector in _mappings(params):
if selector.get("output_role") is not None:
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selectors are only supported in feature.selectors",
))
for selector_index, selector in enumerate(node.selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector",
"This feature contract cannot consume a feature output role selector",
selector_index=selector_index,
))
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires kind face and owner_feature_id",
selector_index=selector_index,
))
if selector.get("source") != "runtime_snapshot":
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires runtime_snapshot evidence",
selector_index=selector_index,
))
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector cannot mix stable or geometry evidence",
selector_index=selector_index,
))
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
source = nodes_by_id.get(str(source_owner or ""))
source_params = (source.params if source is not None else {}) or {}
if not isinstance(source_owner, str) or not isinstance(source_role, str):
blockers.append(self._blocker(
node.feature_id, "invalid_output_role_source",
"An output role source requires owner_feature_id and output_role",
selector_index=selector_index,
))
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"Output role sources are currently supported only for shell.offset_face",
selector_index=selector_index,
))
elif source_role not in {"extrude.start", "extrude.end"} or (
source is None
or source.atomic_id != "extrude_add_blind"
or source_params.get("result_mode") != "new_body"
or (source_params.get("end_condition") or {}).get("type") != "blind"
):
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"shell.offset_face requires a direct new_body blind extrusion cap source",
selector_index=selector_index,
))
if node.atomic_id == "sweep_add":
path = params.get("path")
segment = path.get("segment") if isinstance(path, dict) else None
@@ -389,18 +640,29 @@ class CapabilityAnalyzer:
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
target_instance_refs = params.get("target_pattern_instance_refs")
tool_ids = params.get("tool_feature_ids")
tool_instance_refs = params.get("tool_pattern_instance_refs")
operation = params.get("operation")
if operation not in {"union", "subtract", "intersect"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_boolean_operation",
"booleanBodies requires union, subtract or intersect",
))
for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)):
if not isinstance(feature_ids, list) or not feature_ids:
target_members: set[str] = set()
tool_members: set[str] = set()
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
):
if feature_ids is None:
feature_ids = []
if instance_refs is None:
instance_refs = []
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
blockers.append(self._blocker(
node.feature_id, "missing_boolean_bodies",
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
"booleanBodies requires explicit target and tool body references", parameter=parameter,
))
continue
for source_id in feature_ids:
@@ -408,18 +670,222 @@ class CapabilityAnalyzer:
if source is None:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature does not exist", missing_feature_id=source_id,
"booleanBodies source feature does not exist", source_feature_id=source_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature did not become executable", missing_feature_id=source_id,
"booleanBodies source feature did not become executable", source_feature_id=source_id,
))
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=source_id,
))
else:
selected_members.add(str(source_id))
for index, reference in enumerate(instance_refs):
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object", parameter=instance_parameter, index=index,
))
continue
instance = reference.get("instance_index")
if not isinstance(instance, int):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference requires an integer instance_index",
parameter=instance_parameter, index=index,
))
continue
member_id = pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
instance,
)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=reference.get("pattern_feature_id"),
source_feature_id=reference.get("source_feature_id"),
instance_index=reference.get("instance_index"),
))
continue
selected_members.add(member_id)
if target_members & tool_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_overlap",
"booleanBodies targets and tools must be disjoint",
))
if node.atomic_id == "shell" and params.get("target_feature_id") is not None:
target_feature_id = params.get("target_feature_id")
required.append("shell:explicit_target_body")
target = nodes_by_id.get(str(target_feature_id or ""))
if not isinstance(target_feature_id, str) or not target_feature_id:
blockers.append(self._blocker(
node.feature_id, "invalid_shell_target_body",
"shell target_feature_id must name one preceding body member",
))
elif target is None:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature does not exist",
target_feature_id=target_feature_id,
))
elif target.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature did not become executable",
target_feature_id=target_feature_id,
))
elif target.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target no longer has an independently selectable body output",
target_feature_id=target_feature_id,
))
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
source_ids = params.get(parameter)
pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else []
transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else []
if source_ids is None:
source_ids = []
if pattern_instances is None:
pattern_instances = []
if transform_copies is None:
transform_copies = []
if (
not isinstance(source_ids, list)
or not isinstance(pattern_instances, list)
or not isinstance(transform_copies, list)
or not (source_ids or pattern_instances or transform_copies)
):
blockers.append(self._blocker(
node.feature_id, "missing_body_sources",
f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter,
))
else:
for source_id in source_ids:
source = nodes_by_id.get(str(source_id))
if source is None:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source feature does not exist", source_feature_id=source_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source did not become executable", source_feature_id=source_id,
))
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=source_id,
))
for reference in pattern_instances:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object",
))
continue
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
pattern = nodes_by_id.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner is not a preceding circular or mirror pattern",
pattern_feature_id=pattern_id,
))
continue
if pattern.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner did not become executable",
pattern_feature_id=pattern_id,
))
continue
count = int(pattern.params.get("pattern_count") or 0)
excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()}
surviving_instance = (
isinstance(instance, int)
and (
(pattern.atomic_id == "pattern_mirror" and instance == 1)
or (
pattern.atomic_id == "pattern_circular"
and 1 <= instance < count
and instance not in excluded
)
)
)
if (
not surviving_instance
or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()}
):
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance reference is not a surviving source copy",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
continue
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
for reference in transform_copies:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_transform_copy_ref",
"Transform COPY body reference must be an object",
))
continue
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
transform = nodes_by_id.get(transform_id)
if transform is None or transform.atomic_id != "transform_bodies":
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner is not a preceding body transform",
transform_feature_id=transform_id,
))
continue
if transform.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner did not become executable",
transform_feature_id=transform_id,
))
continue
transform_sources = transform.params.get("source_feature_ids") or []
if (
not bool(transform.params.get("make_copy"))
or not isinstance(transform_sources, list)
or len(transform_sources) < 2
or source_id not in {str(value) for value in transform_sources}
):
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY reference is not a source-qualified multi-body copy",
transform_feature_id=transform_id, source_feature_id=source_id,
))
continue
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY source has no independently selectable body output",
transform_feature_id=transform_id, source_feature_id=source_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
@@ -429,7 +895,7 @@ class CapabilityAnalyzer:
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
if (
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"}
or end_type != "blind"
):
blockers.append(self._blocker(
@@ -469,7 +935,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("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}")
@@ -501,7 +967,7 @@ class CapabilityAnalyzer:
))
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}:
if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face"}:
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
if len(face_selectors) != 1 or len(node.selectors) != 1:
blockers.append(self._blocker(
@@ -604,10 +1070,9 @@ class CapabilityAnalyzer:
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
if status == "executable":
completed.add(node.feature_id)
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
body_producers = {
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"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", "bend_add", "gear_add", "rack_add",
+142 -9
View File
@@ -103,6 +103,12 @@
"required": ["distance_mm"],
"additionalProperties": false
},
"extrudeFromFaceParams": {
"type": "object",
"properties": {"distance_mm": {"$ref": "#/$defs/number"}, "operation": {"enum": ["add", "cut"]}, "reverse": {"type": "boolean"}, "reverse_distance_mm": {"$ref": "#/$defs/number"}, "two_sided": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}, "reverse_end_condition": {"$ref": "#/$defs/endCondition"}, "draft": {"$ref": "#/$defs/extrudeDraft"}, "result_mode": {"enum": ["fuse", "new_body"]}},
"required": ["distance_mm", "operation"],
"additionalProperties": false
},
"extrudeDraft": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90}, "pull_direction": {"type": "boolean"}},
@@ -379,7 +385,7 @@
},
"shellParams": {
"type": "object",
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}},
"properties": {"thickness_mm": {"type": "number", "exclusiveMinimum": 0}, "inward": {"type": "boolean"}, "target_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"required": ["thickness_mm"],
"additionalProperties": false
},
@@ -388,10 +394,74 @@
"properties": {
"operation": {"enum": ["union", "subtract", "intersect"]},
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"keep_tools": {"type": "boolean"}
},
"required": ["operation", "target_feature_ids", "tool_feature_ids"],
"required": ["operation"],
"allOf": [
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
],
"additionalProperties": false
},
"bodyTransform": {
"type": "object",
"properties": {
"type": {"enum": ["translation", "rotation", "uniform_scale"]},
"translation_mm": {"$ref": "#/$defs/point3"},
"axis": {"$ref": "#/$defs/axis"},
"angle_deg": {"type": "number"},
"center_mm": {"$ref": "#/$defs/point3"},
"scale_factor": {"type": "number", "exclusiveMinimum": 0}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "translation"}}, "required": ["type"]}, "then": {"required": ["translation_mm"]}},
{"if": {"properties": {"type": {"const": "rotation"}}, "required": ["type"]}, "then": {"required": ["axis", "angle_deg"]}},
{"if": {"properties": {"type": {"const": "uniform_scale"}}, "required": ["type"]}, "then": {"required": ["center_mm", "scale_factor"]}}
],
"additionalProperties": false
},
"patternInstanceBodyRef": {
"type": "object",
"properties": {
"pattern_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"instance_index": {"type": "integer", "minimum": 1}
},
"required": ["pattern_feature_id", "source_feature_id", "instance_index"],
"additionalProperties": false
},
"transformCopyBodyRef": {
"type": "object",
"properties": {
"transform_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"required": ["transform_feature_id", "source_feature_id"],
"additionalProperties": false
},
"transformBodiesParams": {
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"transform": {"$ref": "#/$defs/bodyTransform"},
"make_copy": {"type": "boolean"}
},
"required": ["transform", "make_copy"],
"anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}],
"additionalProperties": false
},
"deleteBodiesParams": {
"type": "object",
"properties": {
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}
},
"required": ["target_feature_ids"],
"additionalProperties": false
},
"linearPatternParams": {
@@ -469,12 +539,30 @@
"required": ["hole_type", "diameter_mm", "depth_mm", "end_condition"],
"additionalProperties": false
},
"featureOutputRole": {
"enum": [
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end",
"shell.offset_face", "shell.closing_descendant", "shell.body_face"
]
},
"outputRoleSource": {
"type": "object",
"properties": {
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"output_role": {"$ref": "#/$defs/featureOutputRole"}
},
"required": ["owner_feature_id", "output_role"],
"additionalProperties": false
},
"selectorRef": {
"type": "object",
"properties": {
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"stable_id": {"type": "string", "minLength": 1},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"output_role_source": {"$ref": "#/$defs/outputRoleSource"},
"owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,160}$"},
"owner_match_required": {"type": "boolean"},
"geometry": {"type": "object"},
"source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]},
"snapshot_id": {"type": "string", "minLength": 1},
@@ -484,7 +572,8 @@
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "stable_id", "source", "confidence"],
"required": ["kind", "source", "confidence"],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
"additionalProperties": false
},
"analyticSegment": {
@@ -498,8 +587,8 @@
"major_radius_mm": {"$ref": "#/$defs/positive"},
"minor_radius_mm": {"$ref": "#/$defs/positive"},
"major_axis": {"$ref": "#/$defs/point2"},
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
"parameters": {"type": "array", "minItems": 3, "items": {"type": "number"}},
"points": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point2"}},
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"parameterization": {"enum": ["chord", "centripetal"]},
"clockwise": {"type": "boolean"},
@@ -512,7 +601,8 @@
{"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": "ellipse"}}}, "then": {"required": ["center", "major_radius_mm", "minor_radius_mm", "major_axis"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}},
{"if": {"properties": {"type": {"const": "bspline"}, "points": {"maxItems": 2}}}, "then": {"required": ["start_tangent", "end_tangent", "parameters"], "properties": {"periodic": {"const": false}}}}
],
"additionalProperties": false
},
@@ -555,7 +645,46 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "sweep_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "boolean_bodies", "reference_plane", "reference_axis", "hole_wizard"]},
"imprintSourceEntity": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 160},
"curve": {"$ref": "#/$defs/analyticSegment"}
},
"required": ["id", "curve"],
"additionalProperties": false
},
"imprintFragment": {
"type": "object",
"properties": {
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"side": {"enum": [-1, 1]},
"intersection_index": {"type": "integer", "minimum": 0}
},
"required": ["anchor_entity_id", "side"],
"additionalProperties": false
},
"imprintSelection": {
"type": "object",
"properties": {
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"face_side": {"enum": [-1, 1]},
"fragment": {"$ref": "#/$defs/imprintFragment"}
},
"required": ["source_entity_id", "face_side"],
"additionalProperties": false
},
"planarImprintProfile": {
"type": "object",
"properties": {
"type": {"const": "planar_imprint"},
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
},
"required": ["type", "source_entities", "selections"],
"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_from_face", "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", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -578,6 +707,7 @@
{"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": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
@@ -600,6 +730,8 @@
{"if": {"properties": {"atomic_id": {"const": "chamfer"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/chamferParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "shell"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/shellParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "boolean_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/booleanBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "transform_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/transformBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "delete_bodies"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/deleteBodiesParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_linear"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/linearPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_mirror"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/mirrorPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
@@ -608,12 +740,13 @@
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
]
},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours"]},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
"profile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"}
{"$ref": "#/$defs/analyticProfile"},
{"$ref": "#/$defs/planarImprintProfile"}
]
},
"sketch": {
@@ -0,0 +1,28 @@
"""Executor modules for the session runtime.
Importing this package registers every atomic executor into
``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the
registry covers the complete declared atomic-id set. Executor modules must
not import each other; shared helpers live in ``common``.
"""
from __future__ import annotations
from ..registry import ALL_ATOMIC_IDS, EXECUTORS
from . import ( # noqa: F401 (importing the modules performs registration)
bodies,
context,
dressup,
extrude,
holes,
loft_sweep,
parametric,
patterns,
primitives,
revolve,
surfaces,
)
_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS))
if _missing:
raise RuntimeError(f"atomic ids without a registered executor: {_missing}")
@@ -0,0 +1,124 @@
"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies).
These operate on explicitly named body members instead of the aggregate
session body, so adjacent independent solids never accidentally become tools
or targets of one another.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..specs import transform_copy_member_id
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta
from .common import _combine_members, _member_sources
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
params = node.params
target_ids = _member_sources(
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
)
tool_ids = _member_sources(
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
)
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
target = _combine_members(session, targets)
tool = _combine_members(session, tools)
operation = str(params.get("operation") or "")
topology_delta: TopologyDelta | None = None
if operation == "union":
result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool)
elif operation == "subtract":
result, topology_delta = session.adapter.cut_with_topology_delta(target, tool)
elif operation == "intersect":
result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool)
else:
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
members = {
feature_id: body
for feature_id, body in session.body_members.items()
if feature_id not in set(target_ids + tool_ids)
}
members[node.feature_id] = result
if bool(params.get("keep_tools")):
members.update(tools)
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
)
return session.result(node)
@atomic_executor("boolean_bodies")
def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_boolean_bodies(node, session)
def _execute_transform_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# FeatureScript transform targets explicit bodies. Do not move the
# aggregate session body, because it may include unrelated members.
source_ids = _member_sources(
node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True,
)
make_copy = bool(node.params.get("make_copy"))
direct_sources = node.params.get("source_feature_ids") or []
if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1:
# The aggregate is only an export compound. Each source transform has
# its own B-rep builder and is the only output a later COPY query may
# select. Do not attach an aggregate topology delta to source members.
members = dict(session.body_members)
members.update({
transform_copy_member_id(node.feature_id, source_id): session.adapter.transform(
session.body_members[source_id], dict(node.params.get("transform") or {}),
)
for source_id in source_ids
})
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members,
)
return session.result(node)
source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids})
transformed, topology_delta = session.adapter.transform_with_topology_delta(
source, dict(node.params.get("transform") or {}),
)
members = dict(session.body_members)
if not make_copy:
for feature_id in source_ids:
members.pop(feature_id)
members[node.feature_id] = transformed
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
)
return session.result(node)
@atomic_executor("transform_bodies")
def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_transform_bodies(node, session)
def _execute_delete_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# Deletion is a body-graph operation, never a Boolean subtraction. A
# selected member can be disjoint or overlap another independent body.
source_ids = _member_sources(node, session, "target_feature_ids")
members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)}
if members:
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
else:
session.clear_body()
return session.result(node)
@atomic_executor("delete_bodies")
def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_delete_bodies(node, session)
@@ -0,0 +1,488 @@
"""Helpers shared by executor family modules.
Every function here is imported by two or more executor modules. Anything
used by exactly one family lives in that family's module instead.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Callable
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
from ..runtime_base import ExtentVector, FeatureExecutionError
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec:
raw_axis = node.params.get("axis") or {}
if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None:
return AxisSpec.from_mapping(raw_axis)
selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None
if not isinstance(selector, dict):
selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
if not isinstance(selector, dict):
raise FeatureExecutionError(
"missing_revolve_axis",
"Revolve requires an explicit axis or an owner-qualified reference-axis selector",
)
resolution = session.resolve(selector)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved")
if not isinstance(resolution.record.value, AxisSpec):
raise FeatureExecutionError(
"unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind,
)
return resolution.record.value
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
if direction_normal_dot > 1e-7:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
if origin_plane_offset > 1e-6:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
)
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
"""Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely.
"""
members: dict[str, Any] = {}
for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool)
if abs(float(result.volume)) > 1e-12:
members[feature_id] = result
return members
def _extruded_tool(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
) -> tuple[Any, TopologyDelta | None]:
"""Build one extrude tool, retaining caps only from one exact builder result."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft")
taper_deg = 0.0
if isinstance(draft, dict):
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None
solids: list[Any] = []
for face in faces:
for extent in extents:
if draft is not None:
if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("extrude produced no solid")
return tool, topology_delta
def _apply_primary_tool(
node: FeaturePlanNode,
session: "ExecutionSession",
tool: Any,
*,
cutting: bool,
topology_delta: TopologyDelta | None = None,
) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
members = _cut_explicit_body_members(session, tool)
if not members:
session.clear_body()
return session.result(node)
body = session.adapter.cut(session.body, tool)
topology_delta = None
elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
)
return session.result(node)
def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult:
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
# 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景),
# 否则按 sketch_id 从会话草图表中取原始草图。
selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch")
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
faces = session.adapter.faces_for_sketch(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
if len(faces) != 1:
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
)
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("revolve requires angle_deg > 0")
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
# 侧实现,使三方合同一致。
if bool(node.params.get("reverse")):
angle = -angle
tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("revolve produced no solid")
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
)
def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any:
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("booleanBodies produced no result bodies")
return body
def _pattern_instance_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str = "pattern_instance_refs",
) -> list[str]:
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
resolved: list[str] = []
for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict):
raise ValueError("pattern instance reference must be an object")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
if not pattern_id or not source_id or not isinstance(instance, int):
raise ValueError("pattern instance reference is incomplete")
pattern = session.nodes.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
params = pattern.params
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
raise ValueError("pattern instance source is not selected by its pattern")
count = int(params.get("pattern_count") or 0)
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
if (
pattern.atomic_id == "pattern_mirror" and instance != 1
) or (
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError("pattern instance is outside the pattern's surviving instances")
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in session.body_members:
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or ():
if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
if not transform_id or not source_id:
raise ValueError("transform COPY reference is incomplete")
transform = session.nodes.get(transform_id)
params = transform.params if transform is not None else {}
sources = params.get("source_feature_ids") or []
if (
transform is None
or transform.atomic_id != "transform_bodies"
or not bool(params.get("make_copy"))
or not isinstance(sources, list)
or len(sources) < 2
or source_id not in {str(value) for value in sources}
):
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in session.body_members:
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _member_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str,
*,
pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False,
) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session))
if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
if missing:
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
return source_ids
def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
def point(value: list[float]) -> Vector3:
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
def _register_added_solid(
session: "ExecutionSession",
node: FeaturePlanNode,
solid: Any,
) -> None:
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
When ``node.params['result_mode'] == "new_body"`` the primitive is kept as
an independent body member so that downstream ``boolean_bodies`` can
reference it without pulling in the accumulated fuse history. The current
body is replaced by a Compound that preserves both, matching the
``extrude_add_blind`` ``new_body`` semantics. Any other value (including
missing) falls back to the legacy fuse-into-body behavior.
"""
if node.params.get("result_mode") == "new_body":
combined = session.adapter.combine(session.body, solid)
members = {**session.body_members, node.feature_id: solid}
session.register_body(node.feature_id, combined, replay_node=node, body_members=members)
return
fused = session.adapter.fuse(session.body, solid)
session.register_body(node.feature_id, fused, replay_node=node)
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")
geometry = resolution.record.geometry
return PlaneSpec.from_mapping({
"origin_mm": geometry["center_mm"],
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
"normal": geometry["normal"],
})
def _hole_starts(
spec: HoleSpec,
*,
host_plane: PlaneSpec,
positions_are_local: bool,
) -> list[Vector3]:
starts: list[Vector3] = []
for point in spec.positions_mm:
if positions_are_local:
start = vector_add(
vector_add(
vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
vector_scale(host_plane.y_dir, point[1]),
),
vector_scale(host_plane.normal, point[2]),
)
else:
start = point
starts.append(start)
return starts
def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]:
resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
def is_body_boundary(edge: Any) -> bool:
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
# 单边选择,所以这里只约束由 FACE 展开的候选边。
face_count = sum(
1
for face in session.body.faces()
if any(candidate.is_same(edge) for candidate in face.edges())
)
return face_count >= 2
edges: list[Any] = []
for item in resolved:
if item.record.kind == "edge":
edges.append(item.record.value)
elif item.record.kind == "face":
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
if not edges:
raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]:
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
# 应抽壳的成员。
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
records = [item.record for item in resolved if item.record is not None]
if not records or any(record.kind != "face" for record in records):
raise ValueError("shell selectors must resolve to faces")
target_ids = {record.body_id for record in records}
if len(target_ids) != 1:
raise ValueError("shell faces must belong to one target body")
target_id = next(iter(target_ids))
members = session.adapter.body_solids(session.body)
if len(members) == 1:
target = members[0]
else:
if target_id is None or session.body_id is None:
raise ValueError("shell target body is unresolved")
prefix = f"{session.body_id}:"
if not target_id.startswith(prefix):
raise ValueError("shell target body is outside the active body set")
try:
member_index = int(target_id[len(prefix):])
except ValueError as error:
raise ValueError("shell target body has an invalid member id") from error
if member_index < 0 or member_index >= len(members):
raise ValueError("shell target body member is unavailable")
target = members[member_index]
target_feature_id = node.params.get("target_feature_id")
if target_feature_id is not None:
if not isinstance(target_feature_id, str) or not target_feature_id:
raise ValueError("shell target_feature_id is invalid")
declared = session.body_members.get(target_feature_id)
if declared is None:
raise ValueError("shell target body is no longer an independently selectable member")
declared_solids = session.adapter.body_solids(declared)
if len(declared_solids) != 1:
raise ValueError("shell target body must resolve to exactly one active solid")
if not declared_solids[0].is_same(target):
raise ValueError("shell target body does not match the resolved face member")
return target, [record.value for record in records]
def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any:
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return replacement
replaced = False
result = None
for member in members:
if member.is_same(target):
result = session.adapter.combine(result, replacement)
replaced = True
else:
result = session.adapter.combine(result, member)
if not replaced or result is None:
raise ValueError("shell target solid is no longer part of the active body")
return result
@@ -0,0 +1,63 @@
"""Reference-geometry executors (reference_plane / reference_axis).
Context features produce no solid; they register durable topology contexts
that later features resolve through owner-qualified selectors.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit
from ..topology import FeaturePlanNode, FeatureResult
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@atomic_executor("reference_plane")
def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
# 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。
plane = PlaneSpec.from_mapping(node.params.get("plane") or {})
# 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。
session.topology.register_context(node.feature_id, plane)
# 3. 返回结果对象,并将该平面作为上下文一并携带。
return session.result(node, context=plane)
@atomic_executor("reference_axis")
def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
# 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。
# 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。
params = node.params.get("axis") or {}
if params.get("origin_mm") and params.get("direction"):
axis = AxisSpec.from_mapping(params)
else:
# 2. 否则从特征选择器中筛选出已解析的基准平面。
planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"]
resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)]
# 3. 校验:轴需要两个非平行的平面,不足两个则报错。
if len(resolved) < 2:
raise ValueError("reference axis requires two uniquely resolved planes")
# 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。
first, second = resolved[0], resolved[1]
n1, n2 = first.normal, second.normal
direction = vector_cross(n1, n2)
squared_length = vector_dot(direction, direction)
if squared_length <= 1e-18:
raise ValueError("reference planes are parallel and cannot define an axis")
# 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。
d1 = vector_dot(n1, first.origin_mm)
d2 = vector_dot(n2, second.origin_mm)
point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length)
# 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。
axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis"))
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
session.topology.register_context(node.feature_id, axis)
return session.result(node, context=axis)
@@ -0,0 +1,120 @@
"""Dress-up executors (fillet / chamfer / shell).
These mutate an existing body through edge/face selectors resolved from the
current B-rep snapshot.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta
from .common import _replace_shell_target, _selector_edges, _shell_target
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
# 1. 校验:圆角作用于已有主体,必须先有主体。
if session.body is None:
raise ValueError("fillet has no body")
# 2. 解析圆角半径并校验必须大于 0。
radius = float(node.params.get("radius_mm") or 0)
if radius <= 0:
raise ValueError("fillet radius_mm must be > 0")
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
body, topology_delta = session.adapter.fillet_with_topology_delta(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
# 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@atomic_executor("fillet")
def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_fillet(node, session)
def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。
# 1. 校验:倒角作用于已有主体,必须先有主体。
if session.body is None:
raise ValueError("chamfer has no body")
# 2. 解析主距离并校验必须大于 0。
distance = float(node.params.get("distance_mm") or 0)
if distance <= 0:
raise ValueError("chamfer distance_mm must be > 0")
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
# 倒角仅保证量级正确,距离所在侧可能反转。
distance_2 = node.params.get("distance_2_mm")
angle_rad = node.params.get("angle_rad")
if distance_2 is None and angle_rad is not None:
distance_2 = distance * math.tan(float(angle_rad))
# 4. 解析目标边(支持相切传播),执行倒角。
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
diagnostics: list[RuntimeDiagnostic] = []
topology_delta: TopologyDelta | None = None
try:
body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges)
except ValueError as error:
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
# 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。
if distance_2 is not None or not session.surface_members:
raise
try:
body = session.adapter.surface_limited_chamfer(
session.body, distance, edges, list(session.surface_members.values()),
)
except ValueError:
raise error
diagnostics.append(RuntimeDiagnostic(
"chamfer_surface_limited",
"Chamfer was limited by an explicit coaxial surface boundary",
feature_id=node.feature_id,
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node, diagnostics=diagnostics)
@atomic_executor("chamfer")
def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_chamfer(node, session)
def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。
if session.body is None:
raise ValueError("shell has no body")
thickness = float(node.params.get("thickness_mm") or 0)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
target, faces = _shell_target(node, session)
result, topology_delta = session.adapter.shell_with_topology_delta(
target, faces, thickness, inward=bool(node.params.get("inward", True)),
)
session.register_body(
node.feature_id, _replace_shell_target(session, target, result), replay_node=node,
topology_delta=topology_delta,
)
return session.result(node)
@atomic_executor("shell")
def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_shell(node, session)
@@ -0,0 +1,42 @@
"""Extrusion executors (blind / two-sided / cut / through / from-face)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult
from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@atomic_executor(
"extrude_add_blind",
"extrude_add_blind_with_hole",
"extrude_add_two_sided",
"extrude_cut_blind",
"extrude_cut_two_sided",
"extrude_cut_through",
)
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _shape_from_primary(node, session, sketch=sketch)
def _execute_extrude_from_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved")
face = resolved[0].record.value
tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session)
return _apply_primary_tool(
node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta,
)
@atomic_executor("extrude_from_face")
def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_from_face(node, session)
@@ -0,0 +1,78 @@
"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..specs import HoleSpec, PlaneSpec, vector_scale
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
from .common import _hole_starts, _host_plane
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore")
def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_hole(node, session, wizard=False)
@atomic_executor("hole_wizard")
def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_hole(node, session, wizard=True)
def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult:
# 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。
# 1. 校验:孔是切除操作,必须先有主体。
if session.body is None:
raise ValueError("hole feature has no body")
# 2. 确定宿主面 host_face:
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
# 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。
host = PlaneSpec.from_mapping(host_selector["frame"])
positions_are_local = True
else:
# 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。
selectors = list(node.selectors)
if isinstance(host_selector, dict):
selectors.append(host_selector)
selector = next((item for item in selectors if item.get("kind") == "face"), None)
if selector is None:
raise ValueError("hole requires host_face selector or frame")
host = _host_plane(session.resolve(selector))
positions_are_local = False
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
# 4. A host-face normal is an outward B-rep orientation, so its inverse
# always enters the material. Inferring direction from the global body
# centre fails for concave or multi-leg parts: for example, the top face
# of an L bracket can sit below the whole body's centre and the old rule
# drilled outward, producing a no-op feature reported as successful.
# The selected topology face is the local, authoritative orientation.
inward = vector_scale(host.normal, -1)
# 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。
tool = session.adapter.hole_tool(
spec,
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
inward,
session.adapter.body_span(session.body, inward) + 2.0,
)
# 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
diagnostics: list[RuntimeDiagnostic] = []
if wizard and node.params.get("thread"):
diagnostics.append(RuntimeDiagnostic(
code="thread_decoration_ignored",
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id,
))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node, diagnostics=diagnostics)
@@ -0,0 +1,89 @@
"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult
from .common import _sweep_path
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
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, topology_delta = session.adapter.loft_with_topology_delta(profiles)
body = session.adapter.fuse(session.body, solid)
# Fusing a loft into an existing body replaces its subshapes through a
# different builder. Only an initial direct loft can expose this builder's
# cap evidence for the final B-rep snapshot.
if session.body is not None:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@atomic_executor("loft_add")
def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add(node, session)
def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved")
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_with_cap_face(resolved[0].record.value, profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
@atomic_executor("loft_add_with_cap_face")
def _loft_cap_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add_with_cap_face(node, session)
def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None = None) -> FeatureResult:
profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None:
raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid, topology_delta = session.adapter.sweep_with_topology_delta(
faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)),
)
is_new_body = node.params.get("result_mode") == "new_body"
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
# A union rebuilds topology, so the pipe-shell builder cannot prove the
# final aggregate's relations. The independent-body path retains its exact
# subshape identity and may expose evidence for the new member.
if session.body is not None and not is_new_body:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
return session.result(node)
@atomic_executor("sweep_add")
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@@ -0,0 +1,118 @@
"""Parametric feature executors (thread / bend / gear / rack).
Each executor parses its runtime-neutral spec from ``runtime_types`` specs,
asks the geometry adapter to build and place the local-frame solid, then
fuses it into the active body (or subtracts, for thread_cut).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
from .common import _register_added_solid
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _execute_thread(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
solid = session.adapter.thread_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
return session.result(node)
def _execute_thread_cut(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
tool = session.adapter.thread_solid(spec)
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
@atomic_executor("thread_add", "thread_cut")
def _thread_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
# 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
del sketch
if node.atomic_id == "thread_cut":
return _execute_thread_cut(node, session)
return _execute_thread(node, session)
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)后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
return session.result(node)
@atomic_executor("bend_add")
def _bend_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
# 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。
del sketch
return _execute_bend(node, session)
def _execute_gear(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。
# 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。
spec = GearSpec.from_feature(node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。
solid = session.adapter.gear_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
# 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。
diagnostics: list[RuntimeDiagnostic] = []
if spec.teeth_count < 17:
diagnostics.append(RuntimeDiagnostic(
code="undercut_risk",
message=(
f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; "
"standard involute geometry is generated without profile shift"
),
feature_id=node.feature_id,
))
return session.result(node, diagnostics=diagnostics)
@atomic_executor("gear_add")
def _gear_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
# 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。
del sketch
return _execute_gear(node, session)
def _execute_rack(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。
# 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。
spec = RackSpec.from_feature(node.params)
# 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。
solid = session.adapter.rack_solid(spec)
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
return session.result(node)
@atomic_executor("rack_add")
def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
# 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。
del sketch
return _execute_rack(node, session)
@@ -0,0 +1,382 @@
"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular).
Instances replay their source features with transformed parameters rather
than copying the current body. NEW-body sources can additionally be
instanced as rigid body-graph copies, keeping each instance independently
addressable for later COPY/DELETE queries.
"""
from __future__ import annotations
import math
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable
from ..capabilities import pattern_transform_blocker
from ..pattern_transform import (
_box_circular_is_exact,
_mirrored_node,
_mirrored_sketch,
_normal_is_coordinate_axis,
_pattern_operation_node,
_rotated_node,
_rotated_sketch,
_translated_node,
_translated_sketch,
)
from ..registry import atomic_executor, execute_node
from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult]
def _execute_linear_pattern(
node: FeaturePlanNode,
session: "ExecutionSession",
execute: ExecutorFunction,
) -> FeatureResult:
# 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。
# 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。
params = node.params
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("pattern source features have no replay definitions")
# 2. 解析两个方向的实例数量。
count_1 = int(params.get("pattern_count_1") or 1)
count_2 = int(params.get("pattern_count_2") or 1)
# 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。
direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0))
direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0))
# 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。
for first in range(count_1):
for second in range(count_2):
if first == 0 and second == 0:
continue
# 计算当前实例相对源特征的偏移向量。
offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second))
for source in sources:
# 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"pattern source uses an unsupported {dependency}")
cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
# 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放,
# 而非复制当前主体做近似。
# A later pattern may select this pattern feature. The definition is
# replayed recursively, never approximated by copying the current body.
session.replay_definitions[node.feature_id] = node
return session.result(node)
@atomic_executor("pattern_linear")
def _linear_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_linear_pattern(node, session, execute_node)
def _execute_mirror_pattern(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
mirror = node.params.get("mirror_plane") or {}
resolution = session.resolve(mirror)
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()]
if (
source_ids
and all(source_id in session.body_members for source_id in source_ids)
and all(
(source := session.nodes.get(source_id)) is not None
and source.params.get("result_mode") == "new_body"
for source_id in source_ids
)
):
# Only a direct NEW body has a standalone source identity after a
# mirror. A hole, dress-up, or ordinary additive source is merely an
# aggregate successor and must use the feature-replay path below.
# Keeping this condition identical to capability preflight prevents a
# downstream COPY body query from selecting an arbitrary aggregate.
members = dict(session.body_members)
body = session.body
for source_id in source_ids:
mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value)
members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored
body = session.adapter.fuse(body, mirrored)
if body is None:
raise ValueError("mirror pattern produced no body")
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
return session.result(node)
if node.params.get("mirror_current_body"):
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
# 丢失后续 cut/fillet 并生成独立错误实体。
if session.body is None:
raise ValueError("mirror current body has no active body")
mirrored = session.adapter.mirror(session.body, resolution.record.value)
session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node)
return session.result(node)
sources = session.replay_sources(node.params.get("source_feature_ids") or [])
if not sources:
raise ValueError("mirror pattern source features have no replay definitions")
for source in sources:
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal):
# box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向,
# 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面
# (法向平行于任一坐标轴)的镜像仍然精确。
raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes")
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
sketch = session.sketches.get(str(source.sketch_id))
execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
session.replay_definitions[node.feature_id] = node
return session.result(node)
@atomic_executor("pattern_mirror")
def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_mirror_pattern(node, session)
def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: "ExecutionSession", axis: AxisSpec) -> bool:
"""Whether rotating a direct circular extrusion creates no new geometry."""
if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}:
return False
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
return False
profile = sketch.get("profile") or {}
circle = profile if profile.get("type") == "circle" else None
if circle is None:
contours = profile.get("contours") or []
segments = (contours[0] or {}).get("segments") if len(contours) == 1 else []
circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None
center = (circle or {}).get("center")
if not isinstance(center, list) or len(center) != 2:
return False
try:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
except (TypeError, ValueError):
return False
if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7:
return False
world_center = vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))),
)
offset = vector_subtract(world_center, axis.origin_mm)
radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction)))
return math.sqrt(vector_dot(radial, radial)) <= 1e-6
def _advance_copy_topology_records(
records: list[TopologyRecord], topology_delta: TopologyDelta | None,
) -> list[TopologyRecord]:
"""Carry COPY provenance through one exact adapter-history operation.
Pattern copies are separate CDSL results even when their solids fuse into
a single final body. The temporary records here are never selector
candidates themselves. They only retain instance ownership while opaque
OCC history proves a unique subshape continuation to the final snapshot.
"""
if topology_delta is None:
return []
advanced: list[TopologyRecord] = []
for record in records:
values: list[Any] = []
for relation in topology_delta.relations:
if (
relation.kind != record.kind
or relation.event not in {"preserved", "modified"}
or not TopologyRegistry._same_topology_value(record.value, relation.source_value)
):
continue
for value in relation.result_values:
if not any(TopologyRegistry._same_topology_value(value, known) for known in values):
values.append(value)
# A split/merge has no unique COPY owner in the present selector
# contract. Keep the executable model, but do not make a claim that a
# later COPY selector can bind one arbitrary descendant.
if len(values) != 1:
continue
advanced.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=record.feature_id,
body_id=record.body_id,
geometry=dict(record.geometry),
value=values[0],
owner_feature_ids=record.owners,
output_roles=record.output_roles,
output_role_sources=record.output_role_sources,
))
return advanced
def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None:
"""Bridge traced final COPY handles into the one registered body snapshot."""
if not records:
return None
return TopologyDelta(
operation="pattern_circular_copy_snapshot",
relations=tuple(
# ``record.value`` has already passed through every transform/fuse
# builder in this pattern and is an actual final-B-rep handle. The
# identity relation merely connects that evidence to the fresh
# adapter snapshot; it is not a geometric rebinding shortcut.
TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,))
for record in records
),
)
def _has_usable_pattern_body(session: "ExecutionSession", body: Any | None) -> bool:
"""Reject a formally valid but empty OCC boolean result before publishing it."""
if body is None or not session.adapter.body_solids(body):
return False
try:
return abs(float(body.volume)) > 1e-12
except (AttributeError, TypeError, ValueError):
return False
def _execute_circular_pattern(
node: FeaturePlanNode,
session: "ExecutionSession",
execute: ExecutorFunction,
) -> 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)
operation_mode = str(params.get("operation_mode") or "add")
if operation_mode not in {"add", "remove"}:
raise ValueError("circular pattern operation_mode must be add or remove")
excluded = {int(value) for value in params.get("excluded_instance_indices") or []}
if any(instance < 1 or instance >= count for instance in excluded):
raise ValueError("circular pattern excluded instance is outside the generated range")
sources = session.replay_sources(params.get("source_feature_ids") or [])
if not sources:
raise ValueError("circular pattern source features have no replay definitions")
source_ids = [source.feature_id for source in sources]
pre_pattern_members = dict(session.body_members)
if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids):
# A pattern over explicit NEW/kept body members has a stronger contract
# than replay: each copy is an independently addressable rigid image of
# the named source member. Keep the instance keys in the body graph so
# a later CADFS COPY(BODY) transform/delete can name exactly one copy.
members = dict(session.body_members)
body = session.body
traced_copy_records: list[TopologyRecord] = []
for instance in range(1, count):
if instance in excluded:
continue
angle_deg = sweep_angle_deg * instance / count
transform = {
"type": "rotation",
"axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)},
"angle_deg": angle_deg,
}
for source_id in source_ids:
member_id = pattern_instance_member_id(node.feature_id, source_id, instance)
owner_id = f"{node.feature_id}.c{instance}.{source_id}"
source_body = session.body_members[source_id]
copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform)
source_records = session.adapter.topology_records(
source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source",
)
copy_records = _advance_copy_topology_records(source_records, transform_delta)
members[member_id] = copy
body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy)
traced_copy_records = _advance_copy_topology_records(
[*traced_copy_records, *copy_records], fuse_delta,
)
if _has_usable_pattern_body(session, body):
session.register_body(
node.feature_id, body, replay_node=node, body_members=members,
topology_delta=_copy_snapshot_topology_delta(traced_copy_records),
topology_predecessors=traced_copy_records,
)
return session.result(node)
# An OCC boolean may report IsDone/valid for an empty result when a
# copied fused body contains coincident internal topology. The normal
# pattern contract can replay the source feature contribution instead;
# it is the only sound fallback because it keeps source operation,
# sketch frame, and body lifecycle semantics intact.
for instance in range(1, count):
if instance in excluded:
continue
# 实例 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:
# 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与
# 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse,
# 后续非轴对称 source 可能因此丢失已生成的实体分支。
if _circular_source_is_axisymmetric(source, session, axis):
continue
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)
cloned = _pattern_operation_node(cloned, operation_mode)
# CADFS pattern instances are copies of the source result, not
# independent `NEW` operations. Replay them through normal add
# semantics: intersecting or face-sharing instances fuse, while
# spatially separate copies remain separate solids in the result.
if cloned.params.get("result_mode") == "new_body":
cloned = FeaturePlanNode(
cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on,
{key: value for key, value in cloned.params.items() if key != "result_mode"},
cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature,
)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
# 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
# active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
if session.body is None:
raise ValueError("circular pattern produced no body")
# Replaying a fused sole-body source may be more robust than copying its
# full aggregate B-rep (for example, when a rotationally invariant base
# would otherwise be unioned with itself). If that replay still has one
# physical body, the direct source remains a proven alias of the current
# member. Preserve it for a following parts-scoped operation such as
# shell; do not extend this alias across multi-body patterns or multiple
# source members.
members = {node.feature_id: session.body}
if (
len(source_ids) == 1
and len(pre_pattern_members) == 1
and source_ids[0] in pre_pattern_members
and _has_usable_pattern_body(session, session.body)
and len(session.adapter.body_solids(session.body)) == 1
):
members[source_ids[0]] = session.body
session.register_body(node.feature_id, session.body, replay_node=node, body_members=members)
return session.result(node)
@atomic_executor("pattern_circular")
def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_circular_pattern(node, session, execute_node)
@@ -0,0 +1,97 @@
"""Analytic primitive executors (sphere_add / box_add / cylinder_add)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..specs import AxisSpec, PlaneSpec
from ..topology import FeaturePlanNode, FeatureResult
from .common import _register_added_solid
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _execute_sphere(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。
# 1. 解析参数:半径 radius_mm 与球心 center_mm。
radius = float(node.params.get("radius_mm") or 0.0)
center = node.params.get("center_mm") or []
# 2. 校验:半径必须大于 0,球心必须是三维坐标。
if radius <= 0 or len(center) != 3:
raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm")
# 3. 由适配器创建球体实体。
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
# 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
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. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
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. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
_register_added_solid(session, node, solid)
return session.result(node)
@atomic_executor("sphere_add")
def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_sphere(node, session)
@atomic_executor("box_add")
def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_box(node, session)
@atomic_executor("cylinder_add")
def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_cylinder(node, session)
@@ -0,0 +1,17 @@
"""Revolution solid executors (revolve_add / revolve_cut)."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult
from .common import _shape_from_primary
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@atomic_executor("revolve_add", "revolve_cut")
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _shape_from_primary(node, session, sketch=sketch)
@@ -0,0 +1,75 @@
"""Surface-feature executors (extrude_surface / revolve_surface).
Surface features register an independent shell and never touch the active
solid body's fuse/cut lifecycle.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..extents import _normal_from_sketch
from ..registry import atomic_executor
from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit
from ..topology import FeaturePlanNode, FeatureResult
from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _execute_revolve_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与
# 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface revolve has no resolved sketch")
faces = session.adapter.faces_for_sketch(sketch)
if len(faces) != 1 or faces[0].inner_wires():
raise ValueError("surface revolve requires exactly one closed profile without holes")
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("surface revolve requires angle_deg > 0")
if bool(node.params.get("reverse")):
angle = -angle
surface_id = session.register_surface(
node.feature_id,
session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis),
)
return session.result(node, include_body=False, surface_id=surface_id)
@atomic_executor("revolve_surface")
def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_revolve_surface(node, session)
def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为
# shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。
sketch = session.sketches.get(str(node.sketch_id))
if sketch is None:
raise ValueError("surface extrude has no resolved sketch")
direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(node.params.get("reverse")):
direction = vector_scale(direction, -1)
distance = float(node.params.get("distance_mm") or 0.0)
if distance <= 0:
raise ValueError("surface extrude requires distance_mm > 0")
wires = session.adapter.surface_wires_for_sketch(sketch)
surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance))
reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0)
if reverse_distance > 0:
opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance))
surface = session.adapter.combine_surfaces(surface, opposite)
surface_id = session.register_surface(node.feature_id, surface)
return session.result(node, include_body=False, surface_id=surface_id)
@atomic_executor("extrude_surface")
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_surface(node, session)
+231
View File
@@ -0,0 +1,231 @@
"""Extrusion/termination-condition planning for the session runtime.
These helpers turn a CDSL feature's end condition (blind, mid-plane,
through-all, up-to-surface, ...) into one or more :class:`ExtentVector`
displacements. They depend on the session only through its adapter and
selector resolution, never on executors.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .runtime_base import ExtentVector, FeatureExecutionError
from .specs import PlaneSpec, Vector3, vector_dot, vector_scale, vector_subtract, vector_unit
from .topology import FeaturePlanNode
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from .session import ExecutionSession
def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3:
return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal
def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]:
condition = condition or node.params.get("end_condition") or {}
reference = condition.get("reference")
if not isinstance(reference, dict):
raise FeatureExecutionError(
"missing_extent_reference",
"This end condition requires a captured target selector",
extent=condition.get("type"),
)
return reference
def _targeted_extent_vector(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: "ExecutionSession",
condition: str,
*,
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> ExtentVector:
if session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
target = session.body
else:
reference = _extent_reference(node, end_condition)
resolution = session.resolve(reference)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face")
if resolution.record.kind != expected_kind:
raise FeatureExecutionError(
"unsupported_extent_target",
"The resolved target kind is incompatible with this end condition",
extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind,
)
target = resolution.record.value
if condition == "up_to_vertex":
target_point = session.adapter.vertex_coordinates(target)
projections = [
vector_dot(vector_subtract(target_point, point), direction)
for face in faces
for point in session.adapter.profile_sample_points(face)
]
if not projections or min(projections) <= 1e-6:
raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition)
if max(projections) - min(projections) > 1e-5:
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections)
else:
if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces):
# 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始
# 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile
# 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。
try:
next_face = session.adapter.next_body_face_after(
session.body, faces, direction, excluded_face=target,
)
except ValueError as error:
raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error
return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face)
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
# 目标的部分被切掉(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)
raise FeatureExecutionError(code, message, extent=condition) from error
offset = abs(float((end_condition or {}).get("offset_mm") or 0.0))
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0))
if offset:
distance -= offset
if distance <= 1e-6:
raise FeatureExecutionError(
"invalid_extent_offset",
"Offset distance reaches or passes the target extent",
extent=condition, offset_mm=offset,
)
return ExtentVector(vector_scale(direction, distance))
def _side_extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: "ExecutionSession",
*,
end_condition: dict[str, Any],
distance_mm: float,
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``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.
"""
condition = str(end_condition.get("type") or "blind")
distance = abs(float(distance_mm or 0.0))
if condition == "blind":
if distance <= 0:
raise ValueError("blind extent requires distance_mm > 0")
return [ExtentVector(vector_scale(direction, distance))]
if condition == "mid_plane":
if distance <= 0:
raise ValueError("mid_plane extent requires distance_mm > 0")
return [
ExtentVector(vector_scale(direction, distance / 2)),
ExtentVector(vector_scale(direction, -distance / 2)),
]
if condition == "through_all":
if session.body is None:
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
return [ExtentVector(vector_scale(direction, distance))]
return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))]
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
return [
_targeted_extent_vector(
node, faces, direction, session, condition,
end_condition=end_condition, offset_mm=distance,
)
]
raise ValueError(f"unsupported directional extent {condition!r}")
def _extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
sketch: dict[str, Any],
session: "ExecutionSession",
) -> list[ExtentVector]:
return _extent_vectors_from_normal(
node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session,
)
def _extent_vectors_from_normal(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
) -> list[ExtentVector]:
"""Resolve extents from an explicit profile normal.
A derived profile can be an actual B-rep face rather than a sketch. Its
outward normal is just as authoritative as a sketch workplane normal, so
both profile sources share the same bounded extent semantics.
"""
params = node.params
normal = vector_unit(profile_normal, field_name="profile normal")
if bool(params.get("reverse")):
normal = vector_scale(normal, -1)
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 in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("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:
raise ValueError("two-sided extrusion requires reverse_distance_mm > 0")
return [
*_side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
),
*_side_extent_vectors(
node, faces, vector_scale(normal, -1), session,
end_condition=reverse_condition, distance_mm=reverse_distance,
),
]
if condition in {"through_all", "through_all_both", "through_all_and_blind"}:
if session.body is None:
# A first feature with through-all has no body to terminate
# against. The source must provide a usable blind component.
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [ExtentVector(vector_scale(normal, distance))]
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
if condition == "through_all":
return [ExtentVector(vector_scale(normal, span))]
if condition == "through_all_both":
return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))]
# Through-all-and-blind is represented by a through direction plus
# its captured opposite blind direction when available.
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
return [
ExtentVector(vector_scale(normal, span)),
ExtentVector(vector_scale(normal, -(reverse_distance or span))),
]
return _side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
)
@@ -0,0 +1,13 @@
"""Frozen legacy engine paths.
``llm_compiler`` (thin CDSL to build_pack) and ``llm_engine`` (build_pack
executor) predate the session-based runtime and are not used by
``run_cdsl_only``. ``exact_rebuild`` hosts the SolidWorks-exact fallback
rebuilds and the project-specific geometric compensations.
Do not extend these modules; new capability belongs in the session runtime
(``executors/``) or the schema contracts. The top-level ``llm_compiler.py``
and ``llm_engine.py`` shims keep every historical import path working.
"""
from __future__ import annotations
@@ -0,0 +1,253 @@
"""SolidWorks-exact fallback rebuilds and project-specific compensations.
``_run_parameterized`` and ``_run_exact`` replay SolidWorks rebuilds through
generated build123d scripts (``compiler_context``). They are the frozen
exact/parameterized legs of ``rebuild.run_rebuild``; the production path is
``run_cdsl_only`` in the session runtime.
``_apply_geometric_compensations`` is a project-specific workaround for a
feature the SolidWorks export misses. It intentionally lives beside the
legacy paths so a clean checkout of this engine in another project can drop
it without touching the generic code.
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import Any
from ..sketch_solver import resolve_all_sketches
from ..translator import generate_build123d_code
def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行
采用双层IR架构:
Learning IR (CDSL) 提供参数化形状、特征结构
Execution IR (compiler_context) 提供精确坐标
translator 提供经过充分测试的代码生成
"""
import subprocess
import tempfile, os
t0 = time.time()
# 1. 获取 compiler_context (Execution IR: 精确坐标)
compiler_context = cdsl.get("compiler_context") or {}
if not compiler_context:
# 从外部文件加载
ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", ""))
if ctx_file.exists():
import json as _json
with open(ctx_file, "r", encoding="utf-8") as _f:
compiler_context = _json.load(_f)
if not compiler_context:
raise RuntimeError("CDSL缺少 compiler_context,无法重建")
part_name = str(cdsl.get("part_id") or out_step.stem)
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = part_name
# 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用)
# 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR
ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])}
cdsl_sketches = cdsl.get("geometry", {}).get("sketches", [])
for sk in cdsl_sketches:
ctx_sk = ctx_sketches_map.get(sk["id"])
if ctx_sk:
# 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用
if not sk.get("entities"):
sk["entities"] = ctx_sk.get("entities", [])
if not sk.get("contour_edges_mm"):
sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", [])
# 3. 解析 CDSL 的参数化草图 (现在有 entities 可用)
cdsl_resolved = resolve_all_sketches(cdsl)
# 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context
# translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类
cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])}
ctx_sketches = list(context.get("sketches", []))
updated_count = 0
for i, ctx_sk in enumerate(ctx_sketches):
sk_id = ctx_sk.get("id", "")
cdsl_sk = cdsl_resolved_map.get(sk_id)
if cdsl_sk and cdsl_sk.get("profile"):
ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]}
updated_count += 1
if cdsl_sk and cdsl_sk.get("profile_from"):
ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]}
updated_count += 1
context["sketches"] = ctx_sketches
# 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性)
# 5. 读取 gold volume
gold_volume_mm3 = None
if gold_step and gold_step.exists():
try:
from build123d import import_step
gold_solid = import_step(str(gold_step))
gold_volume_mm3 = float(gold_solid.volume)
except Exception:
pass
# 6. 用 translator 生成并执行
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
# 6b. 应用几何补偿 (SW导出缺失的特征)
part_id = str(cdsl.get("part_id") or "")
code = _apply_geometric_compensations(code, part_id)
out_step.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf:
tf.write(code)
script_path = tf.name
try:
r = subprocess.run(
["python", script_path],
capture_output=True, text=True, encoding="utf-8", timeout=120,
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
if r.returncode != 0:
raise RuntimeError(f"Build script failed:\n{r.stderr}")
finally:
try:
os.unlink(script_path)
except Exception:
pass
# 7. 读取重建结果
out_step.parent.mkdir(parents=True, exist_ok=True)
built_step = Path(part_name + ".step")
if not built_step.exists():
built_step = Path.cwd() / (part_name + ".step")
if built_step.exists():
import shutil
shutil.copy2(str(built_step), str(out_step))
built_step.unlink()
else:
raise RuntimeError(f"No STEP output found: {part_name}.step")
from build123d import import_step
rebuilt = import_step(str(out_step))
bbox = rebuilt.bounding_box()
bbox_mm = {
"min": [bbox.min.X, bbox.min.Y, bbox.min.Z],
"max": [bbox.max.X, bbox.max.Y, bbox.max.Z],
}
elapsed = time.time() - t0
return {
"out_step": str(out_step),
"volume_mm3": float(rebuilt.volume),
"bbox_mm": bbox_mm,
"log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"],
"engine": "parameterized",
"elapsed_s": round(elapsed, 1),
}
def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""精确路径: generate_build123d_code (后备)"""
import subprocess
compiler_context = cdsl.get("compiler_context") or {}
part_name = str(cdsl.get("part_id") or out_step.stem)
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = part_name
# Read gold volume if available, for chamfer/candidate scoring
gold_volume_mm3 = None
if gold_step and gold_step.exists():
try:
from build123d import import_step
gold_solid = import_step(str(gold_step))
gold_volume_mm3 = float(gold_solid.volume)
except Exception:
pass
out_step.parent.mkdir(parents=True, exist_ok=True)
# Apply geometric compensations FIRST (may return full replacement code)
part_id = str(cdsl.get("part_id") or "")
compensation_code = _apply_geometric_compensations("", part_id)
if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code:
# 完整替换代码 (跳过generate_build123d_code)
code = compensation_code
else:
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
code = _apply_geometric_compensations(code, part_id)
t0 = time.time()
script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py"
script_path.parent.mkdir(exist_ok=True)
script_path.write_text(code, encoding="utf-8")
completed = subprocess.run(
[sys.executable, str(script_path)],
cwd=out_step.parent,
capture_output=True,
text=True,
timeout=600,
)
if completed.returncode != 0:
raise RuntimeError(
f"Exact compiler FAILED (rc={completed.returncode})\n"
f"STDOUT:\n{completed.stdout[-2000:]}\n"
f"STDERR:\n{completed.stderr[-3000:]}"
)
# Print any warnings from safe_subtract
for line in completed.stdout.split('\n'):
if 'SUBTRACT' in line or 'UNION' in line:
print(f" {line.strip()}")
from build123d import import_step
# 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step"
# 移到 out_step 位置以供后续对比
actual_step = out_step.parent / f"{part_name}.step"
if actual_step.exists():
import shutil
shutil.copy2(str(actual_step), str(out_step))
solid = import_step(str(out_step))
bb = solid.bounding_box()
elapsed = time.time() - t0
return {
"out_step": str(out_step),
"volume_mm3": float(solid.volume),
"bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z]},
"engine": "exact",
"elapsed_s": round(elapsed, 1),
}
# ═══════════════════════════════════════════════════════════════
# Geometric compensations(项目特例;拷贝到其他项目时可删)
# ═══════════════════════════════════════════════════════════════
def _apply_geometric_compensations(code: str, part_id: str) -> str:
"""为SW导出中缺失的特征添加几何补偿切操作"""
if part_id == "113246":
if "export_step(result, " not in code:
return code
comp = (
" # === COMPENSATION: 侧槽 (SW缺失特征) ===\n"
" with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), "
"x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n"
" Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n"
" comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n"
" result = safe_subtract(result, comp_cutter)\n"
)
code = code.replace("export_step(result, ", comp + " export_step(result, ")
return code
@@ -0,0 +1,287 @@
"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
try:
from ..sketch_solver import resolve_all_sketches
except ImportError:
from sketch_solver import resolve_all_sketches
REQUIRED = {
"revolve_add": ["angle_deg", "axis"],
"revolve_cut": ["angle_deg", "axis"],
"extrude_add_blind": ["distance_mm"],
"extrude_add_two_sided": ["distance_mm"],
"extrude_cut_blind": ["distance_mm"],
"hole_blind": ["diameter_mm", "depth_mm"],
"hole_countersink": ["diameter_mm", "depth_mm"],
"hole_counterbore": ["diameter_mm", "depth_mm"],
"sphere_add": ["radius_mm", "center_mm"],
}
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None:
if sketch is None:
return None
s = deepcopy(sketch)
wp = s.get("workplane") or {}
o = list(wp.get("origin_mm") or [0, 0, 0])
wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
s["workplane"] = wp
edges = []
for e in s.get("contour_edges_mm") or []:
ne = deepcopy(e)
for key in ("start_mm", "end_mm", "center_mm"):
if key in ne:
p = ne[key]
ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz]
edges.append(ne)
if edges:
s["contour_edges_mm"] = edges
# 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns
return s
def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]:
p = deepcopy(params)
if "positions" in p:
for pos in p["positions"]:
mm = pos.get("mm")
if mm:
pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz]
if "axis" in p and isinstance(p["axis"], dict):
o = list(p["axis"].get("origin_mm") or [0, 0, 0])
p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
return p
def compile_cdsl(
cdsl: dict[str, Any],
atoms_catalog: dict[str, Any] | None = None,
techniques_catalog: dict[str, Any] | None = None,
) -> dict[str, Any]:
allowed = set()
if atoms_catalog:
allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []}
techniques = {
item["technique_id"]: item
for item in (techniques_catalog or {}).get("techniques") or []
}
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
# 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm
cdsl = resolve_all_sketches(cdsl)
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
steps: list[dict[str, Any]] = []
seen_ids: set[str] = set()
# feature_id -> list of emitted step dicts (for pattern source)
emitted: dict[str, list[dict[str, Any]]] = {}
def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]:
atomic = feature["atomic_id"]
if allowed and atomic not in allowed:
raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog")
for dep in feature.get("depends_on") or []:
if dep not in seen_ids and not any(dep in emitted):
# dependency may be ok if earlier
if dep not in seen_ids:
raise ValueError(f"{step_id}: depends_on {dep} not yet defined")
step = {
"step_id": step_id,
"atomic_id": atomic,
"depends_on": list(feature.get("depends_on") or []),
"params": params,
"sketch": sketch,
"source_name": feature.get("name"),
}
steps.append(step)
seen_ids.add(step_id)
return step
for feat in cdsl.get("features") or []:
fid = feat["id"]
atomic = feat.get("atomic_id")
technique_id = feat.get("technique_id")
if technique_id:
technique = techniques.get(technique_id)
if technique is None:
raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog")
groups = feat.get("params") or {}
expanded: list[dict[str, Any]] = []
previous_step_id: str | None = None
for index, internal in enumerate(technique.get("internal_steps") or [], start=1):
group_name = internal.get("params_from")
group = deepcopy(groups.get(group_name) or {})
if not isinstance(group, dict):
raise ValueError(f"{fid}: parameter group {group_name!r} must be an object")
params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group)
sketch_id = group.get("sketch_id") or params.pop("sketch_id", None)
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
internal_atomic = internal.get("atomic_id")
if not internal_atomic:
raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step")
for key in REQUIRED.get(internal_atomic, []):
if params.get(key) is None:
raise ValueError(
f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}"
)
internal_feature = {
"atomic_id": internal_atomic,
"depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []),
"name": f"{feat.get('name') or technique_id}:{group_name or index}",
}
step_id = f"{fid}.t{index}"
expanded.append(emit(internal_feature, params, sketch, step_id))
previous_step_id = step_id
if len(expanded) < 2:
raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps")
emitted[fid] = expanded
seen_ids.add(fid)
continue
if not atomic:
raise ValueError(f"{fid}: missing atomic_id")
if atomic == "pattern_linear":
params = feat.get("params") or {}
src_ids = params.get("source_feature_ids") or []
c1 = int(params.get("pattern_count_1") or 1)
c2 = int(params.get("pattern_count_2") or 1)
s1 = float(params.get("spacing_1_mm") or 0)
s2 = float(params.get("spacing_2_mm") or 0)
d1 = params.get("direction_1") or [1, 0, 0]
d2 = params.get("direction_2") or [0, 1, 0]
if params.get("direction_1_reverse"):
d1 = [-d1[0], -d1[1], -d1[2]]
if params.get("direction_2_reverse"):
d2 = [-d2[0], -d2[1], -d2[2]]
src_steps: list[dict[str, Any]] = []
for sid in src_ids:
src_steps.extend(emitted.get(sid) or [])
if not src_steps:
# 无源则跳过并记录
steps.append(
{
"step_id": fid,
"atomic_id": "noop_pattern",
"depends_on": list(feat.get("depends_on") or []),
"params": params,
"sketch": None,
"note": "pattern source steps missing",
}
)
seen_ids.add(fid)
continue
clone_steps = []
k = 0
for i in range(c1):
for j in range(c2):
if i == 0 and j == 0:
continue
dx = d1[0] * s1 * i + d2[0] * s2 * j
dy = d1[1] * s1 * i + d2[1] * s2 * j
dz = d1[2] * s1 * i + d2[2] * s2 * j
for src in src_steps:
k += 1
clone_id = f"{fid}.p{k}"
fake_feat = {
"atomic_id": src["atomic_id"],
"depends_on": [steps[-1]["step_id"]] if steps else [],
"name": f"{src.get('source_name')}_pattern",
}
st = emit(
fake_feat,
_offset_params_positions(src["params"], dx, dy, dz),
_offset_sketch(src.get("sketch"), dx, dy, dz),
clone_id,
)
clone_steps.append(st)
emitted[fid] = clone_steps
seen_ids.add(fid)
continue
params = deepcopy(feat.get("params") or {})
sketch_id = feat.get("sketch_id") or params.get("sketch_id")
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
if sketch_id:
params["sketch_id"] = sketch_id
# Auto-derive revolve axis origin
if "revolve" in atomic and sketch and "axis" in params:
ax = params.get("axis") or {}
# 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标
wp = sketch.get("workplane") or {}
wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0]
if ax.get("from_workplane_origin") and "origin_mm" not in ax:
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(wp_origin)
elif "origin_mm" not in ax:
ce = sketch.get("contour_edges_mm") or []
if ce:
idx = int(ax.get("from_contour_vertex", 0))
vertex = ce[idx % len(ce)]["start_mm"]
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(vertex)
for key in REQUIRED.get(atomic, []):
if key == "axis" and "axis" not in params:
raise ValueError(f"{fid}: missing axis")
if key not in ("axis",) and params.get(key) is None and key != "sketch_id":
# positions can be empty temporarily
if key in params:
continue
if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None:
raise ValueError(f"{fid}: missing {key}")
st = emit(feat, params, sketch, fid)
emitted[fid] = [st]
# filter noop
steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"]
return {
"schema": "cad.engine_plan.v1",
"part_id": cdsl.get("part_id"),
"unit": "mm",
"steps": steps,
"compiler_context": deepcopy(cdsl.get("compiler_context")),
"meta": {
"from_cdsl_schema": cdsl.get("schema"),
"compiler": "cad-heard.llm_compiler.v1",
"n_steps": len(steps),
},
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--cdsl", type=Path, required=True)
ap.add_argument("--catalog", type=Path, default=None)
ap.add_argument("--techniques", type=Path, default=None)
ap.add_argument("--out", type=Path, required=True)
args = ap.parse_args()
catalog = _load(args.catalog) if args.catalog else None
techniques = _load(args.techniques) if args.techniques else None
pack = compile_cdsl(_load(args.cdsl), catalog, techniques)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"wrote {args.out} steps={len(pack['steps'])}")
if __name__ == "__main__":
main()
@@ -0,0 +1,555 @@
"""build123d 绘图引擎:执行 build_pack → STEP。"""
from __future__ import annotations
import builtins
import json
import math
import subprocess
import sys
from pathlib import Path
from typing import Any
# 保留内置 float,防止被 build123d 上下文 shadow
_f = builtins.float
from build123d import ( # noqa: E402
Align,
Axis,
BuildPart,
BuildSketch,
Circle,
Cone,
Cylinder,
Edge,
Face,
Location,
Locations,
Mode,
Plane,
Polygon,
Sphere,
Vector,
Wire,
export_step,
extrude,
import_step,
revolve,
)
# Keep this in sync with the execution branches in run_engine_plan. The
# agent-facing schema and its parity test prevent unsupported names reaching
# this low-level dispatcher.
SUPPORTED_ATOMIC_IDS = frozenset({
"extrude_add_blind",
"extrude_add_two_sided",
"extrude_cut_blind",
"revolve_add",
"revolve_cut",
"hole_blind",
"hole_countersink",
"hole_counterbore",
"sphere_add",
"reference_plane",
"reference_axis",
})
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _plane_from_workplane(wp: dict[str, Any]) -> Plane:
o = wp.get("origin_mm") or [0, 0, 0]
x = wp.get("x_dir") or [1, 0, 0]
n = wp.get("normal") or [0, 0, 1]
return Plane(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])),
z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])),
)
def _axis_from_params(axis: dict[str, Any]) -> Axis:
o = axis.get("origin_mm") or [0, 0, 0]
d = axis.get("direction") or [1, 0, 0]
return Axis(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])),
)
def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]:
entities = sketch.get("entities") or []
line_loop = [
i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction")
]
if not line_loop:
raise ValueError(f"sketch {sketch.get('id')}: no profile lines")
pts: list[tuple[float, float]] = []
for i in line_loop:
e = entities[i]
s = (_f(e["start"][0]), _f(e["start"][1]))
en = (_f(e["end"][0]), _f(e["end"][1]))
if not pts:
pts.append(s)
if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4:
if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4:
s, en = en, s
else:
pts.append(s)
pts.append(en)
if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4:
pts.append(pts[0])
return pts
def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector:
"""Return a point on the intended directed arc for ``make_three_point_arc``.
Legacy contour data has no sweep direction and retains its prior shortest
arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a
major arc or a clockwise arc cannot be silently inverted by the adapter.
"""
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
return (p1 + p2) / 2
n = Vector(*(edge.get("normal") or [0, 0, 1]))
if n.length < 1e-9:
n = v1.cross(v2)
if n.length < 1e-9:
n = Vector(0, 0, 1)
n = n.normalized()
v1n = v1.normalized() * radius
if "clockwise" not in edge:
bisector = v1n + v2.normalized() * radius
if bisector.length < 1e-9:
bisector = n.cross(v1n)
return center + bisector.normalized() * radius
sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2))
if bool(edge["clockwise"]):
if sweep >= 0:
sweep -= math.tau
elif sweep <= 0:
sweep += math.tau
half = sweep / 2
midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half)
return center + midpoint_vector
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
b123_edges: list[Edge] = []
for e in edges_mm:
p1 = Vector(*e["start_mm"])
p2 = Vector(*e["end_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
b123_edges.append(Edge.make_line(p1, p2))
continue
mid = _arc_midpoint(e, p1, p2, center, r)
try:
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
b123_edges.append(Edge.make_line(p1, p2))
else:
b123_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(b123_edges))
if desired_normal is not None:
dn = Vector(*desired_normal)
if dn.length > 1e-9:
fn = face.normal_at()
if fn.dot(dn) < 0:
# 重建反转的 Wire:边顺序反转 + 每条边起止点交换
# 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed)
rev_edges: list[Edge] = []
for e in reversed(edges_mm):
p1 = Vector(*e["end_mm"])
p2 = Vector(*e["start_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
rev_edges.append(Edge.make_line(p1, p2))
continue
mid = _arc_midpoint(e, p1, p2, center, r)
try:
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
rev_edges.append(Edge.make_line(p1, p2))
else:
rev_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(rev_edges))
return face
def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float:
dist = abs(_f(params["distance_mm"]))
if prefer_sign == "plus":
return dist
if prefer_sign == "minus":
return -dist
return -dist if bool(params.get("reverse")) else dist
def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None:
"""Build circular islands and holes from containment parity.
A circle contained by one larger circle is a hole; a circle contained by
two larger circles is an island again. This preserves annular profiles
without storing the heavy tessellated sketch regions from the SW export.
"""
ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True)
tolerance = 1e-6
for index, circle in enumerate(ordered):
center = circle["center"]
radius = _f(circle["radius_mm"])
containing = 0
for outer in ordered[:index]:
outer_center = outer["center"]
outer_radius = _f(outer["radius_mm"])
distance = math.hypot(
_f(center[0]) - _f(outer_center[0]),
_f(center[1]) - _f(outer_center[1]),
)
if distance + radius <= outer_radius + tolerance:
containing += 1
mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT
with Locations((_f(center[0]), _f(center[1]))):
Circle(radius, mode=mode)
def run_engine_plan(
pack: dict[str, Any],
out_step: Path,
*,
cut_sign: str = "from_params",
) -> dict[str, Any]:
log: list[str] = []
compiler_context = pack.get("compiler_context")
if isinstance(compiler_context, dict):
# 回退路径:使用本包 translator(不依赖外部 backend.src)
try:
from .translator import generate_build123d_code, get_part_name
except ImportError:
from translator import generate_build123d_code, get_part_name
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem)
out_step.parent.mkdir(parents=True, exist_ok=True)
completed = subprocess.run(
[sys.executable, "-c", generate_build123d_code(context)],
cwd=out_step.parent,
capture_output=True,
text=True,
timeout=180,
)
if completed.returncode != 0:
raise RuntimeError(
f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
)
generated_name = get_part_name({"part_name": context["metadata"]["part_name"]})
generated = out_step.parent / f"{generated_name}.step"
if generated != out_step and generated.exists():
generated.replace(out_step)
if not out_step.exists():
raise RuntimeError(f"exact compiler did not generate {out_step}")
solid = import_step(str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(solid.volume),
"bbox_mm": {
"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z],
},
"engine": "translator_fallback",
}
with BuildPart() as part:
for step in pack.get("steps") or []:
atomic = step["atomic_id"]
params = step["params"]
sketch = step.get("sketch")
sid = step.get("step_id")
if atomic == "reference_plane":
# Context features deliberately produce no solid. They remain
# executable plan steps so their dependencies are preserved and
# can be registered by the session-based runtime.
plane = _plane_from_workplane(params.get("plane") or {})
log.append(
f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}"
)
elif atomic == "reference_axis":
axis = _axis_from_params(params.get("axis") or {})
log.append(
f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}"
)
elif atomic == "sphere_add":
radius = _f(params.get("radius_mm") or 0)
center = params.get("center_mm") or [0, 0, 0]
if radius <= 0 or len(center) != 3:
raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm")
with Locations((_f(center[0]), _f(center[1]), _f(center[2]))):
Sphere(radius, mode=Mode.ADD)
log.append(f"{sid}: sphere_add radius={radius}")
elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
regions = sketch.get("contour_regions_mm") or []
sign = cut_sign if "cut" in atomic else "from_params"
circles = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "circle" and not e.get("construction")
]
lines = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "line" and not e.get("construction")
]
# 多区域轮廓(外环 + 孔):由 shape generator 展开
if regions:
faces = []
normal = (sketch.get("workplane") or {}).get("normal")
for reg in regions:
outer_edges = reg.get("outer") or []
if len(outer_edges) < 2:
continue
face = _face_from_contour_edges(outer_edges, desired_normal=normal)
for hole_edges in reg.get("holes") or []:
if len(hole_edges) < 2:
continue
hole = _face_from_contour_edges(hole_edges, desired_normal=normal)
face = face.cut(hole)
faces.append(face)
if not faces:
raise ValueError(f"{sid}: contour_regions_mm produced no faces")
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
for face in faces:
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
for face in faces:
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} regions={len(faces)}")
continue
# 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除
prefer_circles = bool(circles) and atomic.startswith("extrude_cut")
if prefer_circles:
with BuildSketch(plane):
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
elif len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} contour")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} contour")
elif circles and not lines:
# 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。
with BuildSketch(plane):
_build_nested_circle_profiles(circles)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} poly")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} poly")
elif atomic in ("revolve_add", "revolve_cut"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
axis = _axis_from_params(params.get("axis") or {})
angle = _f(params.get("angle_deg") or 360)
mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode)
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
revolve(axis=axis, revolution_arc=angle, mode=mode)
log.append(f"{sid}: {atomic} angle={angle}")
elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"):
dia = _f(params.get("diameter_mm") or 0)
depth = _f(params.get("depth_mm") or 0)
positions = params.get("positions") or []
if sketch is not None:
plane = _plane_from_workplane(sketch.get("workplane") or {})
else:
plane = Plane.XY
host_face = params.get("host_face") or {}
frame = host_face.get("frame") or {}
frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple()))
frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple()))
frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple()))
normal = plane.z_dir.normalized()
bb = part.part.bounding_box()
part_center = Vector(
(bb.min.X + bb.max.X) / 2,
(bb.min.Y + bb.max.Y) / 2,
(bb.min.Z + bb.max.Z) / 2,
)
inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal
for pos in positions:
mm = pos.get("mm") or [0, 0, 0]
start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1])
cs_dia = _f(params.get("countersink_diameter_mm") or 0)
cs_angle = _f(params.get("countersink_angle_rad") or 0)
cb_dia = _f(params.get("counterbore_diameter_mm") or 0)
cb_depth = _f(params.get("counterbore_depth_mm") or 0)
cs_depth = (
((cs_dia - dia) / 2) / math.tan(cs_angle / 2)
if cs_dia > dia and cs_angle > 0
else 0
)
base_offset = cs_depth + (cb_depth if cb_dia > dia else 0)
main_depth = max(0.001, abs(depth) - base_offset)
main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward))
tools = [
Cylinder(
radius=dia / 2,
height=main_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(main_place)
]
if cb_dia > dia and cb_depth > 0:
tools.append(
Cylinder(
radius=cb_dia / 2,
height=cb_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
if cs_depth > 0:
tools.append(
Cone(
bottom_radius=cs_dia / 2,
top_radius=dia / 2,
height=cs_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
drill_angle = _f(params.get("drill_angle_rad") or 0)
if drill_angle > 0:
tip_depth = (dia / 2) / math.tan(drill_angle / 2)
tools.append(
Cone(
bottom_radius=dia / 2,
top_radius=0,
height=tip_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(
Location(
Plane(origin=start + inward * abs(depth), z_dir=inward)
)
)
)
for tool in tools:
part.part = part.part.cut(tool)
log.append(f"{sid}: {atomic} npos={len(positions)}")
else:
raise ValueError(f"unsupported atomic_id: {atomic}")
solid = part.part
out_step.parent.mkdir(parents=True, exist_ok=True)
export_step(solid, str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(solid.volume),
"bbox_mm": {
"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z],
},
"log": log,
"cut_sign": cut_sign,
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--pack", type=Path, required=True)
ap.add_argument("--out-step", type=Path, required=True)
ap.add_argument("--report", type=Path, default=None)
ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"])
args = ap.parse_args()
info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign)
if args.report:
args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print(
json.dumps(
{k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info},
ensure_ascii=False,
indent=2,
)
)
for line in info.get("log") or []:
print(line)
if __name__ == "__main__":
main()
+8 -283
View File
@@ -1,287 +1,12 @@
"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。"""
"""Compatibility shim: the implementation moved to ``legacy.llm_compiler``.
``compile_cdsl`` expands a thin CDSL document into a ``build_pack`` plan.
It predates the session-based runtime and is frozen; new capability belongs
in ``executors/`` and the schema contracts.
"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from .legacy.llm_compiler import REQUIRED, compile_cdsl, main
try:
from .sketch_solver import resolve_all_sketches
except ImportError:
from sketch_solver import resolve_all_sketches
REQUIRED = {
"revolve_add": ["angle_deg", "axis"],
"revolve_cut": ["angle_deg", "axis"],
"extrude_add_blind": ["distance_mm"],
"extrude_add_two_sided": ["distance_mm"],
"extrude_cut_blind": ["distance_mm"],
"hole_blind": ["diameter_mm", "depth_mm"],
"hole_countersink": ["diameter_mm", "depth_mm"],
"hole_counterbore": ["diameter_mm", "depth_mm"],
"sphere_add": ["radius_mm", "center_mm"],
}
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None:
if sketch is None:
return None
s = deepcopy(sketch)
wp = s.get("workplane") or {}
o = list(wp.get("origin_mm") or [0, 0, 0])
wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
s["workplane"] = wp
edges = []
for e in s.get("contour_edges_mm") or []:
ne = deepcopy(e)
for key in ("start_mm", "end_mm", "center_mm"):
if key in ne:
p = ne[key]
ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz]
edges.append(ne)
if edges:
s["contour_edges_mm"] = edges
# 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns
return s
def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]:
p = deepcopy(params)
if "positions" in p:
for pos in p["positions"]:
mm = pos.get("mm")
if mm:
pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz]
if "axis" in p and isinstance(p["axis"], dict):
o = list(p["axis"].get("origin_mm") or [0, 0, 0])
p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
return p
def compile_cdsl(
cdsl: dict[str, Any],
atoms_catalog: dict[str, Any] | None = None,
techniques_catalog: dict[str, Any] | None = None,
) -> dict[str, Any]:
allowed = set()
if atoms_catalog:
allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []}
techniques = {
item["technique_id"]: item
for item in (techniques_catalog or {}).get("techniques") or []
}
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
# 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm
cdsl = resolve_all_sketches(cdsl)
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
steps: list[dict[str, Any]] = []
seen_ids: set[str] = set()
# feature_id -> list of emitted step dicts (for pattern source)
emitted: dict[str, list[dict[str, Any]]] = {}
def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]:
atomic = feature["atomic_id"]
if allowed and atomic not in allowed:
raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog")
for dep in feature.get("depends_on") or []:
if dep not in seen_ids and not any(dep in emitted):
# dependency may be ok if earlier
if dep not in seen_ids:
raise ValueError(f"{step_id}: depends_on {dep} not yet defined")
step = {
"step_id": step_id,
"atomic_id": atomic,
"depends_on": list(feature.get("depends_on") or []),
"params": params,
"sketch": sketch,
"source_name": feature.get("name"),
}
steps.append(step)
seen_ids.add(step_id)
return step
for feat in cdsl.get("features") or []:
fid = feat["id"]
atomic = feat.get("atomic_id")
technique_id = feat.get("technique_id")
if technique_id:
technique = techniques.get(technique_id)
if technique is None:
raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog")
groups = feat.get("params") or {}
expanded: list[dict[str, Any]] = []
previous_step_id: str | None = None
for index, internal in enumerate(technique.get("internal_steps") or [], start=1):
group_name = internal.get("params_from")
group = deepcopy(groups.get(group_name) or {})
if not isinstance(group, dict):
raise ValueError(f"{fid}: parameter group {group_name!r} must be an object")
params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group)
sketch_id = group.get("sketch_id") or params.pop("sketch_id", None)
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
internal_atomic = internal.get("atomic_id")
if not internal_atomic:
raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step")
for key in REQUIRED.get(internal_atomic, []):
if params.get(key) is None:
raise ValueError(
f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}"
)
internal_feature = {
"atomic_id": internal_atomic,
"depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []),
"name": f"{feat.get('name') or technique_id}:{group_name or index}",
}
step_id = f"{fid}.t{index}"
expanded.append(emit(internal_feature, params, sketch, step_id))
previous_step_id = step_id
if len(expanded) < 2:
raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps")
emitted[fid] = expanded
seen_ids.add(fid)
continue
if not atomic:
raise ValueError(f"{fid}: missing atomic_id")
if atomic == "pattern_linear":
params = feat.get("params") or {}
src_ids = params.get("source_feature_ids") or []
c1 = int(params.get("pattern_count_1") or 1)
c2 = int(params.get("pattern_count_2") or 1)
s1 = float(params.get("spacing_1_mm") or 0)
s2 = float(params.get("spacing_2_mm") or 0)
d1 = params.get("direction_1") or [1, 0, 0]
d2 = params.get("direction_2") or [0, 1, 0]
if params.get("direction_1_reverse"):
d1 = [-d1[0], -d1[1], -d1[2]]
if params.get("direction_2_reverse"):
d2 = [-d2[0], -d2[1], -d2[2]]
src_steps: list[dict[str, Any]] = []
for sid in src_ids:
src_steps.extend(emitted.get(sid) or [])
if not src_steps:
# 无源则跳过并记录
steps.append(
{
"step_id": fid,
"atomic_id": "noop_pattern",
"depends_on": list(feat.get("depends_on") or []),
"params": params,
"sketch": None,
"note": "pattern source steps missing",
}
)
seen_ids.add(fid)
continue
clone_steps = []
k = 0
for i in range(c1):
for j in range(c2):
if i == 0 and j == 0:
continue
dx = d1[0] * s1 * i + d2[0] * s2 * j
dy = d1[1] * s1 * i + d2[1] * s2 * j
dz = d1[2] * s1 * i + d2[2] * s2 * j
for src in src_steps:
k += 1
clone_id = f"{fid}.p{k}"
fake_feat = {
"atomic_id": src["atomic_id"],
"depends_on": [steps[-1]["step_id"]] if steps else [],
"name": f"{src.get('source_name')}_pattern",
}
st = emit(
fake_feat,
_offset_params_positions(src["params"], dx, dy, dz),
_offset_sketch(src.get("sketch"), dx, dy, dz),
clone_id,
)
clone_steps.append(st)
emitted[fid] = clone_steps
seen_ids.add(fid)
continue
params = deepcopy(feat.get("params") or {})
sketch_id = feat.get("sketch_id") or params.get("sketch_id")
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
if sketch_id:
params["sketch_id"] = sketch_id
# Auto-derive revolve axis origin
if "revolve" in atomic and sketch and "axis" in params:
ax = params.get("axis") or {}
# 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标
wp = sketch.get("workplane") or {}
wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0]
if ax.get("from_workplane_origin") and "origin_mm" not in ax:
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(wp_origin)
elif "origin_mm" not in ax:
ce = sketch.get("contour_edges_mm") or []
if ce:
idx = int(ax.get("from_contour_vertex", 0))
vertex = ce[idx % len(ce)]["start_mm"]
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(vertex)
for key in REQUIRED.get(atomic, []):
if key == "axis" and "axis" not in params:
raise ValueError(f"{fid}: missing axis")
if key not in ("axis",) and params.get(key) is None and key != "sketch_id":
# positions can be empty temporarily
if key in params:
continue
if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None:
raise ValueError(f"{fid}: missing {key}")
st = emit(feat, params, sketch, fid)
emitted[fid] = [st]
# filter noop
steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"]
return {
"schema": "cad.engine_plan.v1",
"part_id": cdsl.get("part_id"),
"unit": "mm",
"steps": steps,
"compiler_context": deepcopy(cdsl.get("compiler_context")),
"meta": {
"from_cdsl_schema": cdsl.get("schema"),
"compiler": "cad-heard.llm_compiler.v1",
"n_steps": len(steps),
},
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--cdsl", type=Path, required=True)
ap.add_argument("--catalog", type=Path, default=None)
ap.add_argument("--techniques", type=Path, default=None)
ap.add_argument("--out", type=Path, required=True)
args = ap.parse_args()
catalog = _load(args.catalog) if args.catalog else None
techniques = _load(args.techniques) if args.techniques else None
pack = compile_cdsl(_load(args.cdsl), catalog, techniques)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"wrote {args.out} steps={len(pack['steps'])}")
if __name__ == "__main__":
main()
__all__ = ["REQUIRED", "compile_cdsl", "main"]
+8 -551
View File
@@ -1,555 +1,12 @@
"""build123d 绘图引擎:执行 build_pack → STEP。"""
"""Compatibility shim: the implementation moved to ``legacy.llm_engine``.
``run_engine_plan`` executes a legacy ``build_pack`` through build123d.
It predates the session-based runtime and is frozen; new capability belongs
in ``executors/`` and the schema contracts.
"""
from __future__ import annotations
import builtins
import json
import math
import subprocess
import sys
from pathlib import Path
from typing import Any
from .legacy.llm_engine import SUPPORTED_ATOMIC_IDS, main, run_engine_plan
# 保留内置 float,防止被 build123d 上下文 shadow
_f = builtins.float
from build123d import ( # noqa: E402
Align,
Axis,
BuildPart,
BuildSketch,
Circle,
Cone,
Cylinder,
Edge,
Face,
Location,
Locations,
Mode,
Plane,
Polygon,
Sphere,
Vector,
Wire,
export_step,
extrude,
import_step,
revolve,
)
# Keep this in sync with the execution branches in run_engine_plan. The
# agent-facing schema and its parity test prevent unsupported names reaching
# this low-level dispatcher.
SUPPORTED_ATOMIC_IDS = frozenset({
"extrude_add_blind",
"extrude_add_two_sided",
"extrude_cut_blind",
"revolve_add",
"revolve_cut",
"hole_blind",
"hole_countersink",
"hole_counterbore",
"sphere_add",
"reference_plane",
"reference_axis",
})
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _plane_from_workplane(wp: dict[str, Any]) -> Plane:
o = wp.get("origin_mm") or [0, 0, 0]
x = wp.get("x_dir") or [1, 0, 0]
n = wp.get("normal") or [0, 0, 1]
return Plane(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])),
z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])),
)
def _axis_from_params(axis: dict[str, Any]) -> Axis:
o = axis.get("origin_mm") or [0, 0, 0]
d = axis.get("direction") or [1, 0, 0]
return Axis(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])),
)
def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]:
entities = sketch.get("entities") or []
line_loop = [
i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction")
]
if not line_loop:
raise ValueError(f"sketch {sketch.get('id')}: no profile lines")
pts: list[tuple[float, float]] = []
for i in line_loop:
e = entities[i]
s = (_f(e["start"][0]), _f(e["start"][1]))
en = (_f(e["end"][0]), _f(e["end"][1]))
if not pts:
pts.append(s)
if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4:
if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4:
s, en = en, s
else:
pts.append(s)
pts.append(en)
if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4:
pts.append(pts[0])
return pts
def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector:
"""Return a point on the intended directed arc for ``make_three_point_arc``.
Legacy contour data has no sweep direction and retains its prior shortest
arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a
major arc or a clockwise arc cannot be silently inverted by the adapter.
"""
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
return (p1 + p2) / 2
n = Vector(*(edge.get("normal") or [0, 0, 1]))
if n.length < 1e-9:
n = v1.cross(v2)
if n.length < 1e-9:
n = Vector(0, 0, 1)
n = n.normalized()
v1n = v1.normalized() * radius
if "clockwise" not in edge:
bisector = v1n + v2.normalized() * radius
if bisector.length < 1e-9:
bisector = n.cross(v1n)
return center + bisector.normalized() * radius
sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2))
if bool(edge["clockwise"]):
if sweep >= 0:
sweep -= math.tau
elif sweep <= 0:
sweep += math.tau
half = sweep / 2
midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half)
return center + midpoint_vector
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
b123_edges: list[Edge] = []
for e in edges_mm:
p1 = Vector(*e["start_mm"])
p2 = Vector(*e["end_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
b123_edges.append(Edge.make_line(p1, p2))
continue
mid = _arc_midpoint(e, p1, p2, center, r)
try:
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
b123_edges.append(Edge.make_line(p1, p2))
else:
b123_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(b123_edges))
if desired_normal is not None:
dn = Vector(*desired_normal)
if dn.length > 1e-9:
fn = face.normal_at()
if fn.dot(dn) < 0:
# 重建反转的 Wire:边顺序反转 + 每条边起止点交换
# 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed)
rev_edges: list[Edge] = []
for e in reversed(edges_mm):
p1 = Vector(*e["end_mm"])
p2 = Vector(*e["start_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
rev_edges.append(Edge.make_line(p1, p2))
continue
mid = _arc_midpoint(e, p1, p2, center, r)
try:
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
rev_edges.append(Edge.make_line(p1, p2))
else:
rev_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(rev_edges))
return face
def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float:
dist = abs(_f(params["distance_mm"]))
if prefer_sign == "plus":
return dist
if prefer_sign == "minus":
return -dist
return -dist if bool(params.get("reverse")) else dist
def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None:
"""Build circular islands and holes from containment parity.
A circle contained by one larger circle is a hole; a circle contained by
two larger circles is an island again. This preserves annular profiles
without storing the heavy tessellated sketch regions from the SW export.
"""
ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True)
tolerance = 1e-6
for index, circle in enumerate(ordered):
center = circle["center"]
radius = _f(circle["radius_mm"])
containing = 0
for outer in ordered[:index]:
outer_center = outer["center"]
outer_radius = _f(outer["radius_mm"])
distance = math.hypot(
_f(center[0]) - _f(outer_center[0]),
_f(center[1]) - _f(outer_center[1]),
)
if distance + radius <= outer_radius + tolerance:
containing += 1
mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT
with Locations((_f(center[0]), _f(center[1]))):
Circle(radius, mode=mode)
def run_engine_plan(
pack: dict[str, Any],
out_step: Path,
*,
cut_sign: str = "from_params",
) -> dict[str, Any]:
log: list[str] = []
compiler_context = pack.get("compiler_context")
if isinstance(compiler_context, dict):
# 回退路径:使用本包 translator(不依赖外部 backend.src)
try:
from .translator import generate_build123d_code, get_part_name
except ImportError:
from translator import generate_build123d_code, get_part_name
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem)
out_step.parent.mkdir(parents=True, exist_ok=True)
completed = subprocess.run(
[sys.executable, "-c", generate_build123d_code(context)],
cwd=out_step.parent,
capture_output=True,
text=True,
timeout=180,
)
if completed.returncode != 0:
raise RuntimeError(
f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
)
generated_name = get_part_name({"part_name": context["metadata"]["part_name"]})
generated = out_step.parent / f"{generated_name}.step"
if generated != out_step and generated.exists():
generated.replace(out_step)
if not out_step.exists():
raise RuntimeError(f"exact compiler did not generate {out_step}")
solid = import_step(str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(solid.volume),
"bbox_mm": {
"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z],
},
"engine": "translator_fallback",
}
with BuildPart() as part:
for step in pack.get("steps") or []:
atomic = step["atomic_id"]
params = step["params"]
sketch = step.get("sketch")
sid = step.get("step_id")
if atomic == "reference_plane":
# Context features deliberately produce no solid. They remain
# executable plan steps so their dependencies are preserved and
# can be registered by the session-based runtime.
plane = _plane_from_workplane(params.get("plane") or {})
log.append(
f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}"
)
elif atomic == "reference_axis":
axis = _axis_from_params(params.get("axis") or {})
log.append(
f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}"
)
elif atomic == "sphere_add":
radius = _f(params.get("radius_mm") or 0)
center = params.get("center_mm") or [0, 0, 0]
if radius <= 0 or len(center) != 3:
raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm")
with Locations((_f(center[0]), _f(center[1]), _f(center[2]))):
Sphere(radius, mode=Mode.ADD)
log.append(f"{sid}: sphere_add radius={radius}")
elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
regions = sketch.get("contour_regions_mm") or []
sign = cut_sign if "cut" in atomic else "from_params"
circles = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "circle" and not e.get("construction")
]
lines = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "line" and not e.get("construction")
]
# 多区域轮廓(外环 + 孔):由 shape generator 展开
if regions:
faces = []
normal = (sketch.get("workplane") or {}).get("normal")
for reg in regions:
outer_edges = reg.get("outer") or []
if len(outer_edges) < 2:
continue
face = _face_from_contour_edges(outer_edges, desired_normal=normal)
for hole_edges in reg.get("holes") or []:
if len(hole_edges) < 2:
continue
hole = _face_from_contour_edges(hole_edges, desired_normal=normal)
face = face.cut(hole)
faces.append(face)
if not faces:
raise ValueError(f"{sid}: contour_regions_mm produced no faces")
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
for face in faces:
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
for face in faces:
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} regions={len(faces)}")
continue
# 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除
prefer_circles = bool(circles) and atomic.startswith("extrude_cut")
if prefer_circles:
with BuildSketch(plane):
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
elif len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} contour")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} contour")
elif circles and not lines:
# 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。
with BuildSketch(plane):
_build_nested_circle_profiles(circles)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} poly")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} poly")
elif atomic in ("revolve_add", "revolve_cut"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
axis = _axis_from_params(params.get("axis") or {})
angle = _f(params.get("angle_deg") or 360)
mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode)
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
revolve(axis=axis, revolution_arc=angle, mode=mode)
log.append(f"{sid}: {atomic} angle={angle}")
elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"):
dia = _f(params.get("diameter_mm") or 0)
depth = _f(params.get("depth_mm") or 0)
positions = params.get("positions") or []
if sketch is not None:
plane = _plane_from_workplane(sketch.get("workplane") or {})
else:
plane = Plane.XY
host_face = params.get("host_face") or {}
frame = host_face.get("frame") or {}
frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple()))
frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple()))
frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple()))
normal = plane.z_dir.normalized()
bb = part.part.bounding_box()
part_center = Vector(
(bb.min.X + bb.max.X) / 2,
(bb.min.Y + bb.max.Y) / 2,
(bb.min.Z + bb.max.Z) / 2,
)
inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal
for pos in positions:
mm = pos.get("mm") or [0, 0, 0]
start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1])
cs_dia = _f(params.get("countersink_diameter_mm") or 0)
cs_angle = _f(params.get("countersink_angle_rad") or 0)
cb_dia = _f(params.get("counterbore_diameter_mm") or 0)
cb_depth = _f(params.get("counterbore_depth_mm") or 0)
cs_depth = (
((cs_dia - dia) / 2) / math.tan(cs_angle / 2)
if cs_dia > dia and cs_angle > 0
else 0
)
base_offset = cs_depth + (cb_depth if cb_dia > dia else 0)
main_depth = max(0.001, abs(depth) - base_offset)
main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward))
tools = [
Cylinder(
radius=dia / 2,
height=main_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(main_place)
]
if cb_dia > dia and cb_depth > 0:
tools.append(
Cylinder(
radius=cb_dia / 2,
height=cb_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
if cs_depth > 0:
tools.append(
Cone(
bottom_radius=cs_dia / 2,
top_radius=dia / 2,
height=cs_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
drill_angle = _f(params.get("drill_angle_rad") or 0)
if drill_angle > 0:
tip_depth = (dia / 2) / math.tan(drill_angle / 2)
tools.append(
Cone(
bottom_radius=dia / 2,
top_radius=0,
height=tip_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(
Location(
Plane(origin=start + inward * abs(depth), z_dir=inward)
)
)
)
for tool in tools:
part.part = part.part.cut(tool)
log.append(f"{sid}: {atomic} npos={len(positions)}")
else:
raise ValueError(f"unsupported atomic_id: {atomic}")
solid = part.part
out_step.parent.mkdir(parents=True, exist_ok=True)
export_step(solid, str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(solid.volume),
"bbox_mm": {
"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z],
},
"log": log,
"cut_sign": cut_sign,
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--pack", type=Path, required=True)
ap.add_argument("--out-step", type=Path, required=True)
ap.add_argument("--report", type=Path, default=None)
ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"])
args = ap.parse_args()
info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign)
if args.report:
args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print(
json.dumps(
{k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info},
ensure_ascii=False,
indent=2,
)
)
for line in info.get("log") or []:
print(line)
if __name__ == "__main__":
main()
__all__ = ["SUPPORTED_ATOMIC_IDS", "main", "run_engine_plan"]
@@ -30,8 +30,22 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
shape = raw.get("fragment_shape")
params_schema = raw.get("author_params_schema")
injected_paths = raw.get("server_injected_paths")
if not isinstance(shape, dict) or not isinstance(params_schema, dict) or not isinstance(injected_paths, list):
selector_policy = raw.get("selector_policy")
runtime_capability = raw.get("runtime_capability")
if (
not isinstance(shape, dict)
or not isinstance(params_schema, dict)
or not isinstance(injected_paths, list)
or not isinstance(selector_policy, dict)
):
raise ValueError(f"operation contract is incomplete for {atomic_id}")
if not isinstance(runtime_capability, dict) or not all(
isinstance(runtime_capability.get(flag), bool)
for flag in (
"body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
)
):
raise ValueError(f"operation runtime_capability is invalid for {atomic_id}")
properties = params_schema.get("properties")
required = params_schema.get("required")
if not isinstance(properties, dict) or not isinstance(required, list):
@@ -51,5 +65,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
"required_params": materialized_required,
"optional_params": [name for name in properties if name not in materialized_required],
"requires_sketch": shape.get("sketch") == "required",
"selector_slot": selector_policy.get("slot"),
"selector_token_kind": selector_policy.get("token_kind"),
"runtime_capability": dict(runtime_capability),
}
return derived
@@ -0,0 +1,509 @@
"""Parametric transforms for pattern replay (translate / mirror / rotate).
A pattern instance re-executes its source feature with every absolute
coordinate parameter transformed (sketch, host frame, axis, positions,
center, nested mirror-plane references). These helpers own that parameter
algebra; the pattern executors only decide which transform to apply.
"""
from __future__ import annotations
import math
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable
from .specs import AxisSpec, PlaneSpec, Vector3, vector_cross, vector_dot, vector_scale, vector_subtract
from .topology import FeaturePlanNode
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from .session import ExecutionSession
def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]:
output = deepcopy(sketch)
components = offset
workplane = output.get("workplane") or {}
origin = workplane.get("origin_mm") or [0, 0, 0]
workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)]
output["workplane"] = workplane
for key in ("contour_edges_mm", "contour_regions_mm"):
def translate(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] = [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):
for child in value:
translate(child)
translate(output.get(key))
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。
#6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面
是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式
PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。
"""
owner = selector.get("owner_feature_id")
if not owner:
return None
for record in session.topology.records_for_feature(str(owner)):
if record.kind == "plane" and isinstance(record.value, PlaneSpec):
return record.value.as_dict()
return None
def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: "ExecutionSession") -> FeaturePlanNode:
params = deepcopy(node.params)
components = offset
if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"):
params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)]
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 and host_frame.get("origin_mm"):
host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)]
if not positions_are_local:
for position in params.get("positions") or []:
if position.get("mm"):
position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)]
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)]
center = params.get("center_mm")
if center:
# box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心,
# 否则阵列副本会静默重合在原位置。
params["center_mm"] = [float(center[index]) + components[index] for index in range(3)]
_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 引用,随实例平移
# 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像
# 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像
# 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处
# 而非正确位置 2P-x+t。
frame = _owner_plane_frame(session, mirror_plane)
if frame is None:
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
cloned_selector = deepcopy(mirror_plane)
cloned_selector["frame"] = {
"origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)],
"x_dir": list(frame["x_dir"]),
"normal": list(frame["normal"]),
}
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"mirror pattern source feature {source_id} has no replay definition")
temp_id = f"{instance_id}.src.{source_id}"
shifted = _translated_node(source_node, temp_id, offset, 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] = _translated_sketch(source_sketch, offset)
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 _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]:
value = tuple(float(component) for component in point)
offset = value if vector else vector_subtract(value, plane.origin_mm)
mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal)))
return list(mirrored)
def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]:
output = deepcopy(sketch)
workplane = output.get("workplane") or {}
if workplane.get("origin_mm"):
workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane)
for key in ("x_dir", "y_dir", "normal"):
if workplane.get(key):
workplane[key] = _reflect_point(workplane[key], plane, vector=True)
output["workplane"] = workplane
# A reflection reverses handedness. ``PlaneSpec`` reconstructs its local
# y direction as normal x x, so keeping the reflected normal means that
# local y is the inverse of the reflected source y. Profiles represented
# as local circles (rather than already-transformed contour edges) must
# therefore invert v to remain at their actual reflected world position.
def mirror_local_coordinates(value: Any) -> None:
if isinstance(value, dict):
for point_key in ("center", "start", "end"):
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):
for child in value:
mirror_local_coordinates(child)
mirror_local_coordinates(output.get("entities"))
# This is not consumed after sketch resolution, but retaining the same
# local semantics makes an overridden sketch safe to inspect or replay.
mirror_local_coordinates(output.get("profile"))
def mirror(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] = _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():
mirror(child)
elif isinstance(value, list):
for child in value:
mirror(child)
mirror(output.get("contour_edges_mm"))
mirror(output.get("contour_regions_mm"))
return output
def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: "ExecutionSession") -> FeaturePlanNode:
params = deepcopy(node.params)
if isinstance(params.get("plane"), dict):
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
if params["plane"].get(key):
params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm")
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:
for key in ("origin_mm", "x_dir", "normal"):
if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
x_reflected = host_frame.get("x_dir")
n_reflected = host_frame.get("normal")
if x_reflected is not None and n_reflected is not None:
host_frame["y_dir"] = [
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
]
# See _mirrored_sketch: the canonical reflected plane reverses local
# y, so local hole coordinates must do the same.
for position in params.get("positions") or []:
point = position.get("mm")
if isinstance(point, list) and len(point) == 3:
position["mm"] = [float(point[0]), -float(point[1]), float(point[2])]
else:
for position in params.get("positions") or []:
if position.get("mm"):
position["mm"] = _reflect_point(position["mm"], plane)
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
if axis.get("direction"):
axis["direction"] = _reflect_point(axis["direction"], plane, vector=True)
center = params.get("center_mm")
if isinstance(center, list) and len(center) == 3:
# box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern
# 中已被显式拒绝)。
params["center_mm"] = _reflect_point(center, plane)
_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 时,其镜像面引用
# 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve
# 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样
# 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。
frame = _owner_plane_frame(session, mirror_plane)
if frame is None:
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
cloned_selector = deepcopy(mirror_plane)
cloned_selector["frame"] = {
"origin_mm": _reflect_point(frame["origin_mm"], plane),
"x_dir": _reflect_point(frame["x_dir"], plane, vector=True),
"normal": _reflect_point(frame["normal"], plane, vector=True),
}
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"mirror pattern source feature {source_id} has no replay definition")
temp_id = f"{instance_id}.src.{source_id}"
shifted = _mirrored_node(source_node, temp_id, plane, 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] = _mirrored_sketch(source_sketch, plane)
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 _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 _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)
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):
for child in value:
rotate(child)
for key in ("contour_edges_mm", "contour_regions_mm"):
rotate(output.get(key))
return output
def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: "ExecutionSession") -> FeaturePlanNode:
# 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与
# _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转,
# 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/
# 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern
# source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层
# 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。
params = deepcopy(node.params)
plane = params.get("plane")
if isinstance(plane, dict):
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
if plane.get(key):
if key == "origin_mm":
plane[key] = _rotated_point(plane[key], axis, angle_rad)
else:
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad))
path = params.get("path")
path_plane = path.get("workplane") if isinstance(path, dict) else None
if isinstance(path_plane, dict):
if path_plane.get("origin_mm"):
path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad)
for key in ("x_dir", "y_dir", "normal"):
if path_plane.get(key):
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_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)
_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)。
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 _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode:
# REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是
# add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以
# 支持完整的 CDSL replay contract。
if operation_mode != "remove": return node
atomic_id = {
"extrude_add_blind": "extrude_cut_blind",
"extrude_add_two_sided": "extrude_cut_two_sided",
"revolve_add": "revolve_cut",
}.get(node.atomic_id, node.atomic_id)
if atomic_id == node.atomic_id and "cut" not in atomic_id:
raise ValueError("REMOVE pattern source is not a replayable cutting feature")
params = {key: value for key, value in node.params.items() if key != "result_mode"}
return FeaturePlanNode(
node.feature_id, atomic_id, node.name, node.depends_on, params,
node.selectors, node.sketch_id, node.declared_status, node.source_feature,
)
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
File diff suppressed because one or more lines are too long
+43 -272
View File
@@ -1,7 +1,7 @@
"""
CDSL → STEP 重建管道
====================
优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
回退: CDSL + compiler_context → translator
"""
@@ -15,41 +15,43 @@ from pathlib import Path
from typing import Any
try:
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .translator import generate_build123d_code, normalize_to_ir
from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
except ImportError: # 允许直接 python rebuild.py
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
from llm_compiler import compile_cdsl
from llm_engine import run_engine_plan
from translator import generate_build123d_code, normalize_to_ir
from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
force_exact: bool = False) -> dict[str, Any]:
def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None,
force_exact: bool = False) -> dict[str, Any]:
"""主重建入口。
优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。
回退:CDSL + compiler_context 的 translator 路径。
"""
# Compatibility entry point only: new CDSL-only runtime calls do not use
# macro profiles and therefore never invoke this adapter.
cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", [])
# Sketchless parameterized features (for example sphere_add) are fully
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
# that path available rather than forcing an unavailable legacy fallback.
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
# Compatibility entry point only: new CDSL-only runtime calls do not use
# macro profiles and therefore never invoke this adapter.
cdsl = lower_legacy_profiles(cdsl)
sketches = cdsl.get("geometry", {}).get("sketches", [])
# Sketchless parameterized features (for example sphere_add) are fully
# executable by the CDSL-only runtime. ``all([])`` deliberately keeps
# that path available rather than forcing an unavailable legacy fallback.
all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches)
cdsl_only_error: Exception | None = None
if all_drawable and not force_exact:
try:
return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
except Exception as e:
cdsl_only_error = e
cdsl_only_error: Exception | None = None
if all_drawable and not force_exact:
try:
return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
except Exception as e:
cdsl_only_error = e
# 加载 compiler_context(后备路径)
ctx = None
@@ -71,13 +73,13 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No
"references": ir.get("references", []),
"validation_hints": ir.get("validation_hints", {}),
}
if ctx is None and not (cdsl.get("compiler_context")):
if cdsl_only_error is not None:
raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error
raise RuntimeError(
"CDSL-only rebuild is unavailable: every sketch must use a supported "
"self-contained profile."
)
if ctx is None and not (cdsl.get("compiler_context")):
if cdsl_only_error is not None:
raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error
raise RuntimeError(
"CDSL-only rebuild is unavailable: every sketch must use a supported "
"self-contained profile."
)
if ctx is not None:
cdsl["compiler_context"] = ctx
@@ -112,24 +114,24 @@ def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool:
return ptype in SHAPE_GENERATORS
def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""Pure semantic CDSL path with no compiler_context fallback."""
t0 = time.time()
try:
from .runtime import rebuild_cdsl
except ImportError:
from runtime import rebuild_cdsl
slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
result = rebuild_cdsl(slim, out_step, strict=True)
result["engine"] = "cdsl_only"
result["elapsed_s"] = round(time.time() - t0, 1)
result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""Pure semantic CDSL path with no compiler_context fallback."""
t0 = time.time()
try:
from .runtime import rebuild_cdsl
except ImportError:
from runtime import rebuild_cdsl
slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
result = rebuild_cdsl(slim, out_step, strict=True)
result["engine"] = "cdsl_only"
result["elapsed_s"] = round(time.time() - t0, 1)
result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
if gold_step and gold_step.exists():
result["gold_step"] = str(gold_step)
return result
def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"})
pack.pop("compiler_context", None)
return pack
@@ -139,216 +141,6 @@ def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]:
return run_engine_plan(pack, out_step)
def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行
采用双层IR架构:
Learning IR (CDSL) 提供参数化形状、特征结构
Execution IR (compiler_context) 提供精确坐标
translator 提供经过充分测试的代码生成
"""
import subprocess
import tempfile, os
t0 = time.time()
# 1. 获取 compiler_context (Execution IR: 精确坐标)
compiler_context = cdsl.get("compiler_context") or {}
if not compiler_context:
# 从外部文件加载
ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", ""))
if ctx_file.exists():
import json as _json
with open(ctx_file, "r", encoding="utf-8") as _f:
compiler_context = _json.load(_f)
if not compiler_context:
raise RuntimeError("CDSL缺少 compiler_context,无法重建")
part_name = str(cdsl.get("part_id") or out_step.stem)
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = part_name
# 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用)
# 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR
ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])}
cdsl_sketches = cdsl.get("geometry", {}).get("sketches", [])
for sk in cdsl_sketches:
ctx_sk = ctx_sketches_map.get(sk["id"])
if ctx_sk:
# 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用
if not sk.get("entities"):
sk["entities"] = ctx_sk.get("entities", [])
if not sk.get("contour_edges_mm"):
sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", [])
# 3. 解析 CDSL 的参数化草图 (现在有 entities 可用)
cdsl_resolved = resolve_all_sketches(cdsl)
# 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context
# translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类
cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])}
ctx_sketches = list(context.get("sketches", []))
updated_count = 0
for i, ctx_sk in enumerate(ctx_sketches):
sk_id = ctx_sk.get("id", "")
cdsl_sk = cdsl_resolved_map.get(sk_id)
if cdsl_sk and cdsl_sk.get("profile"):
ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]}
updated_count += 1
if cdsl_sk and cdsl_sk.get("profile_from"):
ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]}
updated_count += 1
context["sketches"] = ctx_sketches
# 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性)
# 5. 读取 gold volume
gold_volume_mm3 = None
if gold_step and gold_step.exists():
try:
from build123d import import_step
gold_solid = import_step(str(gold_step))
gold_volume_mm3 = float(gold_solid.volume)
except Exception:
pass
# 6. 用 translator 生成并执行
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
# 6b. 应用几何补偿 (SW导出缺失的特征)
part_id = str(cdsl.get("part_id") or "")
code = _apply_geometric_compensations(code, part_id)
out_step.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf:
tf.write(code)
script_path = tf.name
try:
r = subprocess.run(
["python", script_path],
capture_output=True, text=True, encoding="utf-8", timeout=120,
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
if r.returncode != 0:
raise RuntimeError(f"Build script failed:\n{r.stderr}")
finally:
try:
os.unlink(script_path)
except Exception:
pass
# 7. 读取重建结果
out_step.parent.mkdir(parents=True, exist_ok=True)
built_step = Path(part_name + ".step")
if not built_step.exists():
built_step = Path.cwd() / (part_name + ".step")
if built_step.exists():
import shutil
shutil.copy2(str(built_step), str(out_step))
built_step.unlink()
else:
raise RuntimeError(f"No STEP output found: {part_name}.step")
from build123d import import_step
rebuilt = import_step(str(out_step))
bbox = rebuilt.bounding_box()
bbox_mm = {
"min": [bbox.min.X, bbox.min.Y, bbox.min.Z],
"max": [bbox.max.X, bbox.max.Y, bbox.max.Z],
}
elapsed = time.time() - t0
return {
"out_step": str(out_step),
"volume_mm3": float(rebuilt.volume),
"bbox_mm": bbox_mm,
"log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"],
"engine": "parameterized",
"elapsed_s": round(elapsed, 1),
}
def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
"""精确路径: generate_build123d_code (后备)"""
import subprocess
compiler_context = cdsl.get("compiler_context") or {}
part_name = str(cdsl.get("part_id") or out_step.stem)
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = part_name
# Read gold volume if available, for chamfer/candidate scoring
gold_volume_mm3 = None
if gold_step and gold_step.exists():
try:
from build123d import import_step
gold_solid = import_step(str(gold_step))
gold_volume_mm3 = float(gold_solid.volume)
except Exception:
pass
out_step.parent.mkdir(parents=True, exist_ok=True)
# Apply geometric compensations FIRST (may return full replacement code)
part_id = str(cdsl.get("part_id") or "")
compensation_code = _apply_geometric_compensations("", part_id)
if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code:
# 完整替换代码 (跳过generate_build123d_code)
code = compensation_code
else:
code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3)
code = _apply_geometric_compensations(code, part_id)
t0 = time.time()
script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py"
script_path.parent.mkdir(exist_ok=True)
script_path.write_text(code, encoding="utf-8")
completed = subprocess.run(
[sys.executable, str(script_path)],
cwd=out_step.parent,
capture_output=True,
text=True,
timeout=600,
)
if completed.returncode != 0:
raise RuntimeError(
f"Exact compiler FAILED (rc={completed.returncode})\n"
f"STDOUT:\n{completed.stdout[-2000:]}\n"
f"STDERR:\n{completed.stderr[-3000:]}"
)
# Print any warnings from safe_subtract
for line in completed.stdout.split('\n'):
if 'SUBTRACT' in line or 'UNION' in line:
print(f" {line.strip()}")
from build123d import import_step
# 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step"
# 移到 out_step 位置以供后续对比
actual_step = out_step.parent / f"{part_name}.step"
if actual_step.exists():
import shutil
shutil.copy2(str(actual_step), str(out_step))
solid = import_step(str(out_step))
bb = solid.bounding_box()
elapsed = time.time() - t0
return {
"out_step": str(out_step),
"volume_mm3": float(solid.volume),
"bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z]},
"engine": "exact",
"elapsed_s": round(elapsed, 1),
}
def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]:
from build123d import import_step
import math, random, time
@@ -493,27 +285,6 @@ def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]:
# (下面的不再需要,新逻辑已在_surface_deviation中实现)
# ═══════════════════════════════════════════════════════════════
# Geometric compensations(项目特例;拷贝到其他项目时可删)
# ═══════════════════════════════════════════════════════════════
def _apply_geometric_compensations(code: str, part_id: str) -> str:
"""为SW导出中缺失的特征添加几何补偿切操作"""
if part_id == "113246":
if "export_step(result, " not in code:
return code
comp = (
" # === COMPENSATION: 侧槽 (SW缺失特征) ===\n"
" with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), "
"x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n"
" Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n"
" comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n"
" result = safe_subtract(result, comp_cutter)\n"
)
code = code.replace("export_step(result, ", comp + " export_step(result, ")
return code
# ===========================================================================
# CLI(便携:显式路径,无项目目录假设)
# ===========================================================================
+83
View File
@@ -0,0 +1,83 @@
"""Atomic executor registry and dispatch for the session runtime.
Executors register themselves with :func:`atomic_executor` from their own
modules; ``cdsl_engine.executors`` imports every executor module exactly once
to build the registry. Adding a new atomic operation therefore means adding
a decorated function in a family module -- the registry itself never changes.
``registry`` deliberately imports no executor module, so pattern executors
can re-enter :func:`execute_node` for replay without an import cycle.
"""
from __future__ import annotations
from typing import Any, Callable, Protocol
from .session import ExecutionSession
from .topology import CapabilityResult, FeaturePlanNode, FeatureResult
class AtomicExecutor(Protocol):
atomic_id: str
def preflight(self, node: FeaturePlanNode, session: ExecutionSession) -> CapabilityResult: ...
def execute(self, node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: ...
#: The complete capability contract of this runtime. Every registered
#: executor must name an id from this set, so a mistyped registration fails
#: at import time instead of surfacing as an unknown-atomic blocker later.
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through", "extrude_from_face", "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",
"reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
"thread_add", "thread_cut",
"bend_add",
"gear_add", "rack_add",
})
#: ``atomic_id -> executor``. Populated by ``cdsl_engine.executors``.
EXECUTORS: dict[str, "ExecutorFunction"] = {}
ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
def atomic_executor(*atomic_ids: str) -> Callable[["ExecutorFunction"], "ExecutorFunction"]:
"""Register one executor function for one or more atomic ids.
Registration validates against ``ALL_ATOMIC_IDS`` and rejects duplicates,
keeping the registry consistent with the declared capability contract.
"""
unknown = [atomic_id for atomic_id in atomic_ids if atomic_id not in ALL_ATOMIC_IDS]
if unknown:
raise ValueError(f"cannot register executors for unknown atomic ids: {unknown}")
def decorator(func: "ExecutorFunction") -> "ExecutorFunction":
for atomic_id in atomic_ids:
if atomic_id in EXECUTORS:
raise ValueError(f"duplicate executor registration for {atomic_id!r}")
EXECUTORS[atomic_id] = func
return func
return decorator
def execute_node(
node: FeaturePlanNode,
session: ExecutionSession,
sketch_override: dict[str, Any] | None = None,
) -> FeatureResult:
"""Dispatch one plan node through its registered executor."""
executor = EXECUTORS.get(node.atomic_id)
if executor is None:
raise ValueError(f"No executor registered for {node.atomic_id!r}")
previous_feature_id = session.active_feature_id
session.active_feature_id = node.feature_id
try:
return executor(node, session, sketch_override)
finally:
session.active_feature_id = previous_feature_id
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
"""Shared runtime error types and extent planning values.
These primitives sit below both ``session`` (execution state) and the
executor modules, so geometry-independent helpers can raise the same stable
execution errors without importing the session or any executor.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .topology import RuntimeDiagnostic, Vector3
class RuntimeExecutionError(RuntimeError):
"""A feature execution failure with serializable runtime evidence."""
def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None:
super().__init__(diagnostic.message)
self.diagnostic = diagnostic
self.selector_resolutions = selector_resolutions
class FeatureExecutionError(RuntimeError):
"""An expected feature-level execution rejection with a stable code."""
def __init__(self, code: str, message: str, **detail: Any) -> None:
super().__init__(message)
self.code = code
self.detail = detail
@dataclass(frozen=True)
class ExtentVector:
"""Single-directional extrusion displacement for one profile face.
``trim_to`` stays ``None`` for an exact vector extrusion. When set, the
extent means "extrude until the target face, trimming any profile region
that does not reach it" (up_to_surface trim semantics, issue #5). The
piercing distance is computed inside the adapter, so ``vector`` only
supplies the direction.
"""
vector: Vector3
trim_to: Any | None = None
File diff suppressed because it is too large Load Diff
@@ -15,10 +15,23 @@ from typing import Any
from jsonschema import Draft202012Validator
from .operation_contracts import materialized_feature_contracts
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
def _mappings(value: Any):
"""Yield nested mapping values without treating selector-like data as text."""
if isinstance(value, dict):
yield value
for child in value.values():
yield from _mappings(child)
elif isinstance(value, list):
for child in value:
yield from _mappings(child)
@lru_cache(maxsize=1)
def _schema() -> dict[str, Any]:
path = Path(__file__).with_name("cdsl_schema.json")
@@ -32,6 +45,12 @@ def _validator() -> Draft202012Validator:
return Draft202012Validator(_schema())
@lru_cache(maxsize=1)
def _operation_contracts() -> dict[str, dict[str, Any]]:
path = Path(__file__).with_name("profile_schema.json")
return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
def _schema_error(document: dict[str, Any]) -> str | None:
validator = _validator()
errors = sorted(validator.iter_errors(document), key=lambda error: (list(error.absolute_path), error.message))
@@ -67,10 +86,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
sketch_ids = {str(sketch.get("id") or "") for sketch in sketches}
if len(sketch_ids) != len(sketches) or not all(_ID.fullmatch(item) for item in sketch_ids):
raise ValueError("Sketch ids must be unique valid CDSL identifiers")
for sketch in sketches:
profile = sketch.get("profile") or {}
if profile.get("type") != "planar_imprint":
continue
entities = profile.get("source_entities") or []
entity_ids = [str(entity.get("id") or "") for entity in entities if isinstance(entity, dict)]
if len(entity_ids) != len(entities) or len(set(entity_ids)) != len(entity_ids) or not all(entity_ids):
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint source entity ids must be unique")
known = set(entity_ids)
for index, selection in enumerate(profile.get("selections") or []):
source = str(selection.get("source_entity_id") or "")
fragment = selection.get("fragment") or {}
anchor = str(fragment.get("anchor_entity_id") or "") if fragment else ""
if source not in known:
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an unknown source entity")
if fragment and (anchor not in known or anchor == source):
raise ValueError(f"Sketch {sketch.get('id')} planar_imprint selection {index} has an invalid fragment anchor")
feature_ids: set[str] = set()
preceding_features: dict[str, dict[str, Any]] = {}
deferred: list[str] = []
unresolved: list[dict[str, Any]] = []
contracts = _operation_contracts()
for feature in cdsl.get("features") or []:
fid = str(feature.get("id") or "")
if not _ID.fullmatch(fid) or fid in feature_ids:
@@ -85,14 +123,166 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
raise ValueError(f"Feature {fid} must declare execution_status")
if feature.get("execution_status") == "deferred":
deferred.append(fid)
for index, selector in enumerate(feature.get("selectors") or []):
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
feature_selectors = feature.get("selectors") or []
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
for index, selector in enumerate(feature_selectors):
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if selector.get("output_role") is not None:
if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
if selector.get("source") != "runtime_snapshot":
raise ValueError(f"Feature {fid} selector {index} output role must use runtime_snapshot evidence")
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
source_feature = preceding_features.get(str(source_owner or ""))
source_params = (source_feature or {}).get("params") or {}
if not source_owner or source_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role source requires a preceding owner_feature_id")
if selector.get("output_role") != "shell.offset_face" or feature.get("atomic_id") != "shell":
raise ValueError(
f"Feature {fid} selector {index} output role source is only supported for shell.offset_face"
)
if source_role not in {"extrude.start", "extrude.end"}:
raise ValueError(
f"Feature {fid} selector {index} shell.offset_face source must be an extrude cap role"
)
if (
source_feature is None
or source_feature.get("atomic_id") != "extrude_add_blind"
or source_params.get("result_mode") != "new_body"
or (source_params.get("end_condition") or {}).get("type") != "blind"
):
raise ValueError(
f"Feature {fid} selector {index} shell.offset_face source requires a preceding direct new_body blind extrusion"
)
elif selector.get("output_role_source") is not None:
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
for selector in _mappings(feature):
# ``output_role_source`` is provenance metadata nested inside a
# feature selector, not a selector on its own.
if (
selector.get("output_role") is not None
and selector.get("kind") is not None
and id(selector) not in output_role_selector_ids
):
raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors")
if feature.get("atomic_id") == "shell":
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
if target_feature_id is not None and target_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
if feature.get("atomic_id") == "transform_bodies":
params = feature.get("params") or {}
references = params.get("pattern_instance_refs") or []
for index, reference in enumerate(references):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} pattern instance reference {index} is invalid")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
pattern = preceding_features.get(pattern_id)
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"Feature {fid} pattern instance reference {index} has a forward or unsupported pattern owner")
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
raise ValueError(f"Feature {fid} pattern instance reference {index} names a source outside its pattern")
source = preceding_features.get(source_id)
if (
pattern_atomic_id == "pattern_mirror"
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
):
raise ValueError(
f"Feature {fid} pattern instance reference {index} requires a preceding new_body mirror source"
)
instance = reference.get("instance_index")
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or ()}
if not isinstance(instance, int) or (
pattern_atomic_id == "pattern_mirror" and instance != 1
) or (
pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError(f"Feature {fid} pattern instance reference {index} is not a surviving copy")
copy_references = params.get("transform_copy_refs") or []
for index, reference in enumerate(copy_references):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} transform COPY reference {index} is invalid")
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
transform = preceding_features.get(transform_id)
transform_params = (transform or {}).get("params") or {}
direct_sources = transform_params.get("source_feature_ids") or []
if (
transform is None
or transform.get("atomic_id") != "transform_bodies"
or not bool(transform_params.get("make_copy"))
or not isinstance(direct_sources, list)
or len(direct_sources) < 2
):
raise ValueError(
f"Feature {fid} transform COPY reference {index} requires a preceding multi-source make_copy transform"
)
if source_id not in {str(value) for value in direct_sources}:
raise ValueError(
f"Feature {fid} transform COPY reference {index} names a source outside its transform"
)
for source_id in params.get("source_feature_ids") or []:
source = preceding_features.get(str(source_id))
source_params = (source or {}).get("params") or {}
source_direct_ids = source_params.get("source_feature_ids") or []
if (
source is not None
and source.get("atomic_id") == "transform_bodies"
and bool(source_params.get("make_copy"))
and isinstance(source_direct_ids, list)
and len(source_direct_ids) > 1
):
raise ValueError(
f"Feature {fid} must use transform_copy_refs to select a source of multi-source COPY {source_id}"
)
if feature.get("atomic_id") == "boolean_bodies":
params = feature.get("params") or {}
for parameter in ("target_pattern_instance_refs", "tool_pattern_instance_refs"):
for index, reference in enumerate(params.get(parameter) or []):
if not isinstance(reference, dict):
raise ValueError(f"Feature {fid} {parameter} entry {index} is invalid")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
pattern = preceding_features.get(pattern_id)
pattern_atomic_id = pattern.get("atomic_id") if pattern is not None else None
if pattern is None or pattern_atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"Feature {fid} {parameter} entry {index} has a forward or unsupported pattern owner")
if source_id not in {str(value) for value in (pattern.get("params") or {}).get("source_feature_ids") or ()}:
raise ValueError(f"Feature {fid} {parameter} entry {index} names a source outside its pattern")
source = preceding_features.get(source_id)
if (
pattern_atomic_id == "pattern_mirror"
and (source is None or (source.get("params") or {}).get("result_mode") != "new_body")
):
raise ValueError(
f"Feature {fid} {parameter} entry {index} requires a preceding new_body mirror source"
)
instance = reference.get("instance_index")
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []}
if not isinstance(instance, int) or (
pattern_atomic_id == "pattern_mirror" and instance != 1
) or (
pattern_atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError(f"Feature {fid} {parameter} entry {index} is not a surviving copy")
if feature.get("unresolved"):
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
feature_ids.add(fid)
preceding_features[fid] = feature
return {
"schema_version": version,
+274
View File
@@ -0,0 +1,274 @@
"""Execution session state and the geometry adapter boundary.
``ExecutionSession`` owns the active body, body-member graph, replay
definitions, and selector-resolution evidence. ``GeometryAdapter`` is the
kernel-facing protocol the session consumes; geometry values stay opaque so a
different B-rep backend can replace build123d without touching the runtime.
"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Protocol
from .build123d_adapter import Build123dGeometryAdapter
from .runtime_base import FeatureExecutionError
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
from .topology import (
FeaturePlanNode,
FeatureResult,
RuntimeDiagnostic,
SelectorResolution,
TopologyDelta,
TopologyRecord,
TopologyRegistry,
)
class GeometryAdapter(Protocol):
"""Kernel boundary consumed by the session runtime.
Geometry values remain opaque here. A future adapter may use a different
B-rep kernel as long as it preserves these construction/query contracts.
"""
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def face_normal(self, face: Any) -> Vector3: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
def transform(self, body: Any, transform: dict[str, Any]) -> Any: ...
def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ...
def combine(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ...
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 gear_solid(self, spec: GearSpec) -> Any: ...
def rack_solid(self, spec: RackSpec) -> 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: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ...
def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ...
def profile_sample_points(self, face: Any) -> list[Any]: ...
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ...
def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ...
def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ...
def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ...
def export(self, body: Any, path: str) -> None: ...
@dataclass
class ExecutionSession:
sketches: dict[str, dict[str, Any]]
nodes: dict[str, FeaturePlanNode]
adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter)
topology: TopologyRegistry = field(default_factory=TopologyRegistry)
body: Any | None = None
body_id: str | None = None
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def register_body(
self,
feature_id: str,
body: Any,
*,
replay_node: FeaturePlanNode | None = None,
body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
# 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
# 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。
members = [
(member_id, self.adapter.topology_records(solid, feature_id, member_id))
for index, solid in enumerate(solids)
for member_id in [f"{self.body_id}:{index}"]
]
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
))
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
surface_id = f"surface:{feature_id}"
self.surface_members[feature_id] = surface
for record in self.adapter.topology_records(surface, feature_id, surface_id):
self.topology.register(record)
self.topology.register(TopologyRecord(
record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id,
geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,),
))
return surface_id
def clear_body(self) -> None:
"""Clear the active solid after an explicit deleteBodies result."""
self.body = None
self.body_id = None
self.body_members = {}
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]:
matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None
if matched is not None:
if not isinstance(matched, list) or not matched:
raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches")
resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched]
else:
binding_feature_id = selector.get("binding_feature_id")
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
failures = [item for item in resolved if item.status != "resolved" or item.record is None]
if failures:
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
return [item.record for item in resolved if item.record is not None]
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
components = selector.get("intersection_of")
if self.body is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"),
)
if not isinstance(components, list) or len(components) < 2:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"),
)
try:
face_sets = [self._intersection_component_records(component) for component in components]
if any(record.kind != "face" for records in face_sets for record in records):
raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces")
vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets])
except FeatureExecutionError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail),
)
except ValueError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)),
)
point = self.adapter.vertex_coordinates(vertex)
record = TopologyRecord(
record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"),
kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id,
geometry={"center_mm": list(point)}, value=vertex,
owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])),
)
return SelectorResolution(
selector=selector, status="resolved", record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))
def result(
self,
node: FeaturePlanNode,
*,
context: PlaneSpec | AxisSpec | None = None,
diagnostics: list[RuntimeDiagnostic] | None = None,
include_body: bool = True,
surface_id: str | None = None,
) -> FeatureResult:
result = FeatureResult(
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed",
body_id=self.body_id if include_body else None, surface_id=surface_id,
context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [],
)
self.results[node.feature_id] = result
return result
def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]:
"""Return selected source features in their original history order.
A pattern's exported selection order is not an execution order. In
particular, a boolean cut may appear before its parent boss in the
raw selection array. The CDSL feature list is dependency-ordered by
semantic validation, so it is the stable order for replay.
"""
requested = {str(feature_id) for feature_id in source_feature_ids}
sources = [
feature
for feature_id, feature in self.nodes.items()
if feature_id in requested and feature_id in self.replay_definitions
]
if len(sources) != len(requested):
missing = sorted(requested - {source.feature_id for source in sources})
raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}")
return sources
+120 -7
View File
@@ -82,6 +82,12 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
normal = workplane.get("normal") or [0, 0, 1]
for edge in contours:
output = deepcopy(edge)
if edge["type"] == "circle":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0)
output["normal"] = list(normal)
transformed.append(output)
continue
output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1])
output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1])
if edge["type"] == "arc":
@@ -245,10 +251,19 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
return _ellipse_edges(segment)
if kind == "bspline":
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
converted = [_point(point) for point in points]
periodic = bool(segment.get("periodic"))
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if len(converted) == 2:
if periodic:
raise ValueError("analytic_contours: two-point bspline cannot be periodic")
if _distance(converted[0], converted[1]) <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline endpoints must be distinct")
if start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents")
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
@@ -271,6 +286,8 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: bspline parameters must be finite")
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
if len(converted) == 2 and parameters is None:
raise ValueError("analytic_contours: two-point bspline requires explicit parameters")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
@@ -280,8 +297,6 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
**({"parameters": parameters} if parameters is not None else {}),
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
}
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
if start_tangent is not None:
@@ -293,13 +308,68 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
"""Convert an IMPRINT source while retaining its FeatureScript edge identity.
Closed contour assembly intentionally divides circles into four arcs so
that its loops have explicit vertices. An IMPRINT source id, however,
denotes one logical FeatureScript edge. Splitting that circle before the
OCC arrangement loses the one-to-one source/history mapping and makes a
valid fragment appear to be an ambiguous multi-edge source.
"""
if segment.get("type") != "circle":
return _segment_edges(segment)
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("planar_imprint: circle radius_mm must be > 0")
output: _Ctx = {
"type": "circle",
"center_mm": [float(center[0]), float(center[1]), 0.0],
"radius_mm": radius,
}
if "clockwise" in segment:
output["clockwise"] = bool(segment["clockwise"])
return [output]
def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
points: 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])
spline_points = edge["points_mm"]
if len(spline_points) == 2:
# GeomAPI_Interpolate with exactly two endpoint derivatives is
# the cubic Hermite curve over the explicit parameter span.
# There are no intermediate interpolation points to sample, so
# use its analytical points rather than incorrectly treating
# an otherwise valid curved loop as zero-area.
parameters = edge.get("parameters") or []
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(parameters) != 2 or start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline sampling is unresolved")
parameter_span = float(parameters[1]) - float(parameters[0])
if parameter_span <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline parameter span is degenerate")
start_point, end_point = spline_points
for fraction in (0.25, 0.5, 0.75):
squared = fraction * fraction
cubed = squared * fraction
h00 = 2.0 * cubed - 3.0 * squared + 1.0
h10 = cubed - 2.0 * squared + fraction
h01 = -2.0 * cubed + 3.0 * squared
h11 = cubed - squared
points.append((
h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0])
+ h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]),
h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1])
+ h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]),
))
continue
points.extend((float(point[0]), float(point[1])) for point in spline_points[1:-1])
continue
if edge.get("type") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
@@ -439,7 +509,38 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, []
CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours}
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
Unlike ``analytic_contours``, these curves are intentionally not joined
into a guessed outer wire. FeatureScript's IMPRINT query identifies
regions in the arrangement of all source curves, including open curves
and split fragments, and the geometry adapter chooses those actual B-rep
regions after the split.
"""
source_entities: list[_Ctx] = []
for source in profile.get("source_entities") or []:
source_id = str(source.get("id") or "")
curve = source.get("curve") or {}
if not source_id:
raise ValueError("planar_imprint: source entity id is missing")
edges = _imprint_segment_edges(curve)
if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2:
raise ValueError("planar_imprint: at least two source entities are required")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"planar_imprint": _gen_planar_imprint,
}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
@@ -463,7 +564,10 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
generator = SHAPE_GENERATORS.get(profile.get("type"))
if generator is None:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False}
meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [],
}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")]
@@ -480,6 +584,15 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{
"id": entity["id"],
"edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"],
}
for entity in meta["_imprint_entities"]
]
output["imprint_selections"] = meta["_imprint_selections"]
return output
+619
View File
@@ -0,0 +1,619 @@
"""Runtime-neutral CDSL vector math, frames, and parametric feature specs.
This module deliberately has no build123d dependency. The spec dataclasses
and canonical vector/plane helpers can therefore be used by validation, batch
reporting, and any geometry adapter without importing OCC objects.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass
from math import cos, pi, radians, sqrt
from typing import Any
Vector3 = tuple[float, float, float]
def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str:
"""Return the runtime-only body-member key for one proven pattern copy.
CDSL keeps the three source fields separately, so callers never need to
manufacture this internal key. The body graph uses the same derivation in
runtime and capability preflight.
"""
return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}"
def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str:
"""Return the runtime-only key for one source of a multi-body COPY.
A multi-source ``transform_bodies`` COPY has several independently
addressable outputs. CDSL records the transform and its selected source as
separate fields; the opaque key stays internal to the body graph.
"""
return f"transform:{transform_feature_id}:{source_member_id}:copy"
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
# 予以保留;否则视为偏斜数据,正交化并显式警告。
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
def _vector3(value: Any, *, field_name: str) -> Vector3:
if not isinstance(value, (list, tuple)) or len(value) != 3:
raise ValueError(f"{field_name} must contain three coordinates")
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError) as error:
raise ValueError(f"{field_name} must contain numeric coordinates") from error
def _length(value: Vector3) -> float:
return sqrt(sum(component * component for component in value))
def _unit(value: Vector3, *, field_name: str) -> Vector3:
magnitude = _length(value)
if magnitude <= 1e-12:
raise ValueError(f"{field_name} must be non-zero")
return tuple(component / magnitude for component in value) # type: ignore[return-value]
def _dot(left: Vector3, right: Vector3) -> float:
return sum(a * b for a, b in zip(left, right))
def _cross(left: Vector3, right: Vector3) -> Vector3:
return (
left[1] * right[2] - left[2] * right[1],
left[2] * right[0] - left[0] * right[2],
left[0] * right[1] - left[1] * right[0],
)
def vector_add(left: Vector3, right: Vector3) -> Vector3:
return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value]
def vector_subtract(left: Vector3, right: Vector3) -> Vector3:
return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value]
def vector_scale(value: Vector3, factor: float) -> Vector3:
return tuple(component * factor for component in value) # type: ignore[return-value]
def vector_dot(left: Vector3, right: Vector3) -> float:
return _dot(left, right)
def vector_cross(left: Vector3, right: Vector3) -> Vector3:
return _cross(left, right)
def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3:
return _unit(value, field_name=field_name)
def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]:
"""Normalize a plane sign so source and OCC face orientations compare."""
unit_normal = _unit(normal, field_name="plane.normal")
offset = _dot(unit_normal, point_mm)
for component in unit_normal:
if abs(component) <= 1e-12:
continue
if component < 0:
unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment]
offset = -offset
break
return unit_normal, offset
def normalize_selector_geometry(geometry: Any) -> dict[str, Any]:
"""Convert legacy SolidWorks selector evidence into runtime-neutral units.
Current CDSL records may already carry ``*_mm`` fields. Older exported
evidence instead stores SolidWorks surface parameters, boxes, and areas in
SI units. The selector remains the source of truth; this function only
makes its geometric signature comparable to an OCC topology snapshot.
"""
if not isinstance(geometry, dict):
return {}
result = dict(geometry)
surface = geometry.get("surface")
if isinstance(surface, dict):
surface_type = str(surface.get("type") or "").lower()
if surface_type:
result.setdefault("surface_type", surface_type)
parameters = surface.get("parameters")
if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6:
try:
raw_normal = _vector3(parameters[:3], field_name="selector surface normal")
# SolidWorks evidence uses metres for surface locations.
point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6])
plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type]
result.setdefault("plane_normal", list(plane_normal))
result.setdefault("plane_offset_mm", plane_offset)
except (TypeError, ValueError):
pass
curve = geometry.get("curve")
if isinstance(curve, dict) and curve.get("type"):
result.setdefault("curve_type", str(curve["type"]).lower())
raw_box = geometry.get("box")
if isinstance(raw_box, list) and len(raw_box) == 6:
try:
result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box])
except (TypeError, ValueError):
pass
raw_area = geometry.get("area")
if raw_area is not None:
try:
result.setdefault("area_mm2", float(raw_area) * 1_000_000.0)
except (TypeError, ValueError):
pass
for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")):
value = geometry.get(raw_key)
if isinstance(value, list) and len(value) == 3:
try:
result.setdefault(normalized_key, [float(component) * 1000.0 for component in value])
except (TypeError, ValueError):
pass
return result
@dataclass(frozen=True)
class AxisSpec:
"""Canonical axis with a normalized direction."""
origin_mm: Vector3
direction: Vector3
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec":
return cls(
origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"),
direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"),
)
def as_dict(self) -> dict[str, list[float]]:
return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)}
@dataclass(frozen=True)
class PlaneSpec:
"""Canonical right-handed plane frame.
SolidWorks exports may contain a redundant or non-orthogonal y direction.
The runtime persists the orthonormalized frame so later features all use
the same coordinate system.
"""
origin_mm: Vector3
x_dir: Vector3
y_dir: Vector3
normal: Vector3
@classmethod
def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec":
origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm")
normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal")
x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir")
projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3))
x_dir = _unit(projected_x, field_name="plane.x_dir")
generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
y_raw = value.get("y_dir")
if y_raw is None:
# y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。
y_dir = generated
else:
y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir")
if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL:
# 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向,
# 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。
y_dir = y_vec
else:
# 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。
warnings.warn(
f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}",
UserWarning,
stacklevel=2,
)
y_dir = generated
return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal)
def as_dict(self) -> dict[str, list[float]]:
return {
"origin_mm": list(self.origin_mm),
"x_dir": list(self.x_dir),
"y_dir": list(self.y_dir),
"normal": list(self.normal),
}
@dataclass(frozen=True)
class HoleSpec:
"""Runtime-neutral definition of a cylindrical Hole Wizard operation.
The spec deliberately contains no OCC planes or shapes. The runtime
resolves the host frame and the adapter turns this definition into a
cutting tool, keeping source-contract parsing separate from B-rep work.
"""
diameter_mm: float
depth_mm: float
end_condition: str
positions_mm: tuple[Vector3, ...]
countersink: tuple[float, float] | None = None
counterbore: tuple[float, float] | None = None
@classmethod
def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec":
try:
diameter = float(params.get("diameter_mm") or 0.0)
depth = float(params.get("depth_mm") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError("hole dimensions must be numeric") from error
if diameter <= 0 or depth <= 0:
raise ValueError("hole requires positive diameter_mm and depth_mm")
condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind")
if condition not in {"blind", "through_all", "through_all_both"}:
raise ValueError(f"unsupported hole extent {condition!r}")
raw_positions = params.get("positions") or []
positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict))
if len(positions) != len(raw_positions) or not positions:
raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates")
raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None
raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None
if atomic_id == "hole_countersink":
raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")}
if atomic_id == "hole_counterbore":
raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")}
def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None:
if value is None:
return None
try:
first_value = float(value.get("diameter_mm") or 0.0)
second_value = float(value.get(second) or 0.0)
except (AttributeError, TypeError, ValueError) as error:
raise ValueError(f"{label} dimensions must be numeric") from error
if first_value <= diameter or second_value <= 0:
raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}")
return first_value, second_value
return cls(
diameter_mm=diameter,
depth_mm=depth,
end_condition=condition,
positions_mm=positions,
countersink=dimensions(raw_sink, "angle_rad", "countersink"),
counterbore=dimensions(raw_bore, "depth_mm", "counterbore"),
)
@dataclass(frozen=True)
class ThreadSpec:
"""Runtime-neutral definition of a parametric screw thread.
The spec deliberately contains no OCC planes or shapes. The adapter turns
this definition into a threaded solid segment keeping source-contract
parsing separate from B-rep work.
``axis.origin_mm`` anchors the leading end face of the threaded segment and
``axis.direction`` is the outward thread axis; ``angle_deg`` is the full
flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews).
``internal`` selects the cutting form used by later thread_cut support.
"""
major_diameter_mm: float
minor_diameter_mm: float
pitch_mm: float
length_mm: float
axis: AxisSpec
angle_deg: float = 60.0
internal: bool = False
lefthand: bool = False
relief_length_mm: float = 0.0
crest_radius_mm: float = 0.0
root_radius_mm: float = 0.0
@classmethod
def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec":
try:
major = float(params.get("major_diameter_mm") or 0.0)
minor = float(params.get("minor_diameter_mm") or 0.0)
pitch = float(params.get("pitch_mm") or 0.0)
length = float(params.get("length_mm") or 0.0)
except (TypeError, ValueError) as error:
raise ValueError("thread dimensions must be numeric") from error
if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0:
raise ValueError("thread requires positive major/minor/pitch/length in millimetres")
if minor >= major:
raise ValueError("thread minor diameter must be smaller than the major diameter")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("thread requires an axis definition")
angle = 60.0
raw_angle = params.get("angle_deg")
if raw_angle is not None:
try:
angle = float(raw_angle)
except (TypeError, ValueError) as error:
raise ValueError("thread angle_deg must be numeric") from error
if not 0 < angle < 180:
raise ValueError("thread angle_deg must be between 0 and 180")
relief = float(params.get("relief_length_mm") or 0.0)
crest_r = float(params.get("crest_radius_mm") or 0.0)
root_r = float(params.get("root_radius_mm") or 0.0)
if relief < 0:
raise ValueError("thread relief_length_mm must be non-negative")
if crest_r < 0 or root_r < 0:
raise ValueError("thread crest/root radius must be non-negative")
# 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半,
# 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。
depth_radius = (major - minor) / 2.0
if crest_r + root_r > 0.5 * depth_radius:
raise ValueError("thread crest_radius + root_radius exceeds half the thread depth")
# thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传
# internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓
# 刀具做布尔差会在 coincident faces 上退化)。
internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False))
return cls(
major_diameter_mm=major,
minor_diameter_mm=minor,
pitch_mm=pitch,
length_mm=length,
axis=AxisSpec.from_mapping(raw_axis),
angle_deg=angle,
internal=internal,
lefthand=bool(params.get("lefthand", False)),
relief_length_mm=relief,
crest_radius_mm=crest_r,
root_radius_mm=root_r,
)
@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 GearSpec:
"""Runtime-neutral definition of an involute spur/helical/herringbone gear.
``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO
involute convention: pitch radius ``m*z/2``, base radius
``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm``
is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix
angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical
gear whose helix reverses at mid-width (the mid-plane seam is shared
phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end
face and ``axis.direction`` the outward gear axis.
"""
module_mm: float
teeth_count: int
width_mm: float
axis: AxisSpec
helix_angle_rad: float = 0.0
pressure_angle_rad: float = pi / 9.0 # 20 degrees
herringbone: bool = False
@classmethod
def from_feature(cls, params: dict[str, Any]) -> "GearSpec":
try:
module = float(params.get("module_mm") or 0.0)
width = float(params.get("width_mm") or 0.0)
teeth = int(params.get("teeth_count") or 0)
helix_raw = params.get("helix_angle_rad")
helix = float(helix_raw) if helix_raw is not None else 0.0
pressure_raw = params.get("pressure_angle_rad")
pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0
except (TypeError, ValueError) as error:
raise ValueError("gear dimensions must be numeric") from error
if module <= 0 or width <= 0:
raise ValueError("gear requires positive module_mm and width_mm")
if not 8 <= teeth <= 200:
raise ValueError("gear teeth_count must be between 8 and 200")
if not 0.0 <= helix < radians(45.0):
raise ValueError("gear helix_angle_rad must be within [0, 45) degrees")
if not 0.0 < pressure <= radians(35.0):
raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees")
herringbone = bool(params.get("herringbone", False))
if herringbone and helix <= 0.0:
raise ValueError("herringbone gear requires helix_angle_rad > 0")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("gear requires an axis definition")
return cls(
module_mm=module,
teeth_count=teeth,
width_mm=width,
axis=AxisSpec.from_mapping(raw_axis),
helix_angle_rad=helix,
pressure_angle_rad=pressure,
herringbone=herringbone,
)
@property
def pitch_radius_mm(self) -> float:
return self.module_mm * self.teeth_count / 2.0
@property
def base_radius_mm(self) -> float:
return self.pitch_radius_mm * cos(self.pressure_angle_rad)
@property
def tip_radius_mm(self) -> float:
return self.pitch_radius_mm + self.module_mm
@property
def root_radius_mm(self) -> float:
return self.pitch_radius_mm - 1.25 * self.module_mm
@dataclass(frozen=True)
class RackSpec:
"""Runtime-neutral definition of a straight-sided rack (``rack_add``).
The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``,
addendum ``m`` above the reference line and dedendum ``1.25*m`` below it,
flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact
overall length ``teeth_count * pi * m`` (both ends land in tooth valley
centres). ``thickness_mm`` is the width along the tooth crest direction.
``axis.origin_mm`` is the corner point where the length-start end face,
the thickness-start face and the tooth-valley plane intersect, and
``axis.direction`` is the direction the teeth point (local +Z).
"""
module_mm: float
teeth_count: int
thickness_mm: float
axis: AxisSpec
pressure_angle_rad: float = pi / 9.0 # 20 degrees
@classmethod
def from_feature(cls, params: dict[str, Any]) -> "RackSpec":
try:
module = float(params.get("module_mm") or 0.0)
thickness = float(params.get("thickness_mm") or 0.0)
teeth = int(params.get("teeth_count") or 0)
pressure_raw = params.get("pressure_angle_rad")
pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0
except (TypeError, ValueError) as error:
raise ValueError("rack dimensions must be numeric") from error
if module <= 0 or thickness <= 0:
raise ValueError("rack requires positive module_mm and thickness_mm")
if not 1 <= teeth <= 2000:
raise ValueError("rack teeth_count must be between 1 and 2000")
if not 0.0 < pressure <= radians(35.0):
raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees")
raw_axis = params.get("axis")
if not isinstance(raw_axis, dict):
raise ValueError("rack requires an axis definition")
return cls(
module_mm=module,
teeth_count=teeth,
thickness_mm=thickness,
axis=AxisSpec.from_mapping(raw_axis),
pressure_angle_rad=pressure,
)
@property
def pitch_mm(self) -> float:
return pi * self.module_mm
@property
def length_mm(self) -> float:
return self.pitch_mm * self.teeth_count
@property
def addendum_mm(self) -> float:
return self.module_mm
@property
def dedendum_mm(self) -> float:
return 1.25 * self.module_mm
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
"""B-rep topology evidence export for the build123d adapter.
These module-level functions produce the face/edge/vertex snapshots that
``TopologyRegistry`` stores for selector resolution and that rebuild reports
expose as ``topology_records``. They are pure queries over the current
B-rep: no construction, no mutation, no OCP imports beyond what the shape
objects themselves expose. ``Build123dGeometryAdapter`` forwards to them,
keeping kernel construction and evidence export in separate files.
"""
from __future__ import annotations
import math
from typing import Any
from .specs import canonical_plane_signature
from .topology import TopologyRecord
def body_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积。
bbox = body.bounding_box()
# A feature history can contain several body IDs while still ending in
# one connected solid (for example, a base extrusion followed by hole
# cuts). Count the current OCC result, never feature history entries.
solids = list(body.solids()) if hasattr(body, "solids") else [body]
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"volume_mm3": float(body.volume),
"solid_count": len(solids),
}
def surface_geometry(surface: Any) -> dict[str, Any]:
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
bbox = surface.bounding_box()
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"area_mm2": float(surface.area),
"face_count": len(surface.faces()),
}
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
records: list[TopologyRecord] = []
faces = list(body.faces())
edges = list(body.edges())
vertices = list(body.vertices())
def index_for(shape: Any, candidates: list[Any]) -> int | None:
"""Map a subshape returned by a face/edge back to body topology."""
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
for index, candidate in enumerate(candidates):
if shape.is_same(candidate):
return index
return None
# 1. 建立邻接索引:每条边关联的面集合(edge_faces)。
edge_faces: list[set[int]] = [set() for _edge in edges]
for face_index, face in enumerate(faces):
for edge in face.edges():
edge_index = index_for(edge, edges)
if edge_index is not None:
edge_faces[edge_index].add(face_index)
# 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。
vertex_edges: list[set[int]] = [set() for _vertex in vertices]
for edge_index, edge in enumerate(edges):
for vertex in edge.vertices():
vertex_index = index_for(vertex, vertices)
if vertex_index is not None:
vertex_edges[vertex_index].add(edge_index)
def edge_signature(edge_index: int) -> str:
# 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。
edge = edges[edge_index]
return ":".join((
str(edge.geom_type).split(".")[-1].lower(),
f"{float(edge.length):.6f}",
str(len(edge_faces[edge_index])),
))
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
# 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从
# 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据
# author 传入的 blind-depth 文字猜测“贯穿”。
face_edge_indexes: list[set[int]] = []
face_geometries: list[dict[str, Any]] = []
for index, face in enumerate(faces):
bbox = face.bounding_box()
center = face.center()
normal = face.normal_at()
boundary_edge_indexes = [
edge_index
for edge in face.edges()
if (edge_index := index_for(edge, edges)) is not None
]
geometry = {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z],
"area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(),
"adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes),
}
if geometry["surface_type"] == "plane":
plane_normal, plane_offset = canonical_plane_signature(
(normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z),
)
geometry["plane_normal"] = list(plane_normal)
geometry["plane_offset_mm"] = plane_offset
boundary_loops: list[list[list[float]]] = []
for wire in face.wires():
samples: list[list[float]] = []
for edge in wire.edges():
curve_type = str(edge.geom_type).split(".")[-1].lower()
fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0]
for fraction in fractions:
point = edge.position_at(fraction)
value = [float(point.X), float(point.Y), float(point.Z)]
if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12:
samples.append(value)
if len(samples) >= 3:
boundary_loops.append(samples)
if boundary_loops:
geometry["boundary_loops_mm"] = boundary_loops
elif geometry["surface_type"] in {"cylinder", "cone"}:
axis = face.axis_of_rotation
if axis is None:
# Build123d can omit this optional OCC property for valid
# swept rotational faces. Keep their generic B-rep record
# so a selector-free workflow remains executable; do not
# invent axis/radius evidence for an axis-based selector.
pass
else:
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
else:
geometry["cylinder_role"] = "unknown"
face_edge_indexes.append(set(boundary_edge_indexes))
face_geometries.append(geometry)
records.append(TopologyRecord(
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
geometry=geometry,
))
plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"]
def directly_linked_planes(face_index: int) -> list[int]:
return [
plane_index
for plane_index in plane_indexes
if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index])
]
def same_inner_rotational_channel(first: int, second: int) -> bool:
"""Whether two inner rotational faces share one B-rep bore channel."""
if not face_edge_indexes[first].intersection(face_edge_indexes[second]):
return False
left, right = face_geometries[first], face_geometries[second]
if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner":
return False
left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction")
left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm")
if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)):
return False
try:
left_direction = tuple(float(value) for value in left_axis)
right_direction = tuple(float(value) for value in right_axis)
offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3))
except (TypeError, ValueError):
return False
alignment = sum(left_direction[index] * right_direction[index] for index in range(3))
if abs(alignment) < 1.0 - 1e-6:
return False
axial_offset = sum(offset[index] * left_direction[index] for index in range(3))
radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3))
return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5
inner_rotational_indexes = [
index
for index, geometry in enumerate(face_geometries)
if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner"
]
def channel_plane_indexes(start: int) -> list[int]:
"""Collect endpoint planes through joined, co-axial inner faces.
A countersink or counterbore splits a physical bore into a cone and
a cylinder. The cylinder has only one direct planar neighbour, so
direct adjacency alone cannot prove that the complete channel exits
the part. Traverse shared B-rep edges only across co-axial inner
rotational faces, then inspect the channel's actual plane ends.
"""
pending = [start]
visited: set[int] = set()
endpoints: set[int] = set()
while pending:
index = pending.pop()
if index in visited:
continue
visited.add(index)
endpoints.update(directly_linked_planes(index))
pending.extend(
candidate
for candidate in inner_rotational_indexes
if candidate not in visited and same_inner_rotational_channel(index, candidate)
)
return sorted(endpoints)
def spans_opposed_planes(linked: list[int], axis: Any) -> bool:
if not isinstance(axis, list) or len(axis) != 3:
return False
try:
direction = tuple(float(value) for value in axis)
except (TypeError, ValueError):
return False
return any(
sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99
and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second))
for first in linked
for second in linked
if first < second
)
for index, geometry in enumerate(face_geometries):
if geometry["surface_type"] != "cylinder":
continue
linked = directly_linked_planes(index)
geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked]
channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked
geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked]
geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction"))
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
for index, edge in enumerate(edges):
bbox = edge.bounding_box()
center = edge.center()
vertices = edge.vertices()
geometry = {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length),
"curve_type": str(edge.geom_type).split(".")[-1].lower(),
"adjacent_face_count": len(edge_faces[index]),
}
if geometry["curve_type"] == "circle":
# ``Edge.center()`` is a point on a periodic circle, not its
# geometric centre. Preserve the OCC circle data separately
# so a provenance-backed rotational selector can distinguish
# concentric full circles at different axial locations.
try:
circle_center = edge.arc_center
radius = float(edge.radius)
values = (circle_center.X, circle_center.Y, circle_center.Z, radius)
except (AttributeError, TypeError, ValueError):
values = ()
if values and all(math.isfinite(float(value)) for value in values) and radius > 0:
geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z]
geometry["radius_mm"] = radius
if vertices:
geometry["start_mm"] = list(vertices[0])
geometry["end_mm"] = list(vertices[-1])
records.append(TopologyRecord(
record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge,
geometry=geometry,
))
# 5. 导出顶点记录:含坐标与关联边数。
for index, vertex in enumerate(vertices):
point = [vertex.X, vertex.Y, vertex.Z]
records.append(TopologyRecord(
record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex,
geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])},
))
return records
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
"""Compatibility package for the historical ``cdsl_engine.translator`` module.
The former single-file translator now lives in ``ir`` (SolidWorks plugin JSON
to backend IR), ``codegen`` (backend IR to build123d source), ``runtime_lib``
(frozen generated-script library), and ``common`` (shared helpers). Every
historical import path and symbol keeps working.
"""
from __future__ import annotations
from .common import SW_END_CONDITIONS, THROUGH_CUT_AMOUNT_MM
from .ir import (
_SW_METADATA_FEATURE_TYPES,
_append_feature_source_sketches,
_axis_reference_from_feature_selections,
_axis_reference_from_owned_faces,
_best_extrude_depth_mm,
_choose_pattern_direction_sign,
_clean_null_reference,
_construction_endpoint_degrees,
_contour_entity_indices_from_segments,
_convert_sw_assembly,
_convert_sw_extrude,
_convert_sw_hole,
_convert_sw_imported_body,
_convert_sw_linear_pattern,
_convert_sw_reference,
_convert_sw_revolve,
_convert_sw_sketch,
_convert_sw_sketch_entity,
_editable_param,
_extract_axis_reference,
_extract_edge_selector_points,
_extract_sketch_parameters,
_extrude_depth_from_owned_faces,
_extrude_owned_faces_span_sketch_plane,
_feature_length_dimension_mm,
_feature_selection_selectors,
_feature_selection_source,
_hole_angle_dimension_rad,
_hole_counterbore_depth_dimension_mm,
_hole_counterbore_dimension_mm,
_hole_dimension_value,
_hole_dimension_value_excluding,
_hole_position_entity_flags,
_hole_position_points,
_hole_position_sketches,
_hole_position_to_model,
_hole_primary_depth_mm,
_hole_primary_diameter_mm,
_hole_primary_dimension_fallback,
_host_face_from_feature_selections,
_host_face_from_workplane,
_is_cut_feature,
_is_hole_profile_sketch,
_is_imported_body_feature,
_is_sketch_point_entity,
_is_weak_inferred_axis,
_operation_profile_bbox,
_owned_face_pattern_offsets,
_owned_face_signatures,
_pattern_direction_from_plugin,
_pattern_direction_from_reference,
_reverse_pattern_direction,
_selectable_stable_ids,
_selection_objects,
_selector_has_persistent_reference,
_sketch_bounds,
_sketch_point_mm,
_sketch_point_to_model,
_sketch_point_to_model_bbox,
_sketch_radius_mm,
_sketch_vector_to_model,
_source_bbox_from_plugin_json,
_source_feature,
_source_owned_faces,
_source_pattern_frame,
analyze_parameterization_status,
convert_sw_plugin_json_to_ir,
enrich_rebuild_parameters,
extract_editable_parameters,
normalize_to_ir,
)
from .codegen import (
_active_profile_loops,
_aligned_workplane_for_owned_midplane,
_blind_extrude_face_offset,
_effective_extrude_cut_depth_mm,
_effective_hole_cut_depth_mm,
_extract_mirror_plane_normal,
_extract_mirror_plane_origin,
_find_operation_for_source_feature,
_find_source_operation_for_pattern,
_flip_side_step_inner_radius_mm,
_flip_side_uses_step_ring,
_generate_assembly_compose,
_generate_chamfer,
_generate_extrude,
_generate_fillet,
_generate_hole,
_generate_imported_body_pending,
_generate_linear_pattern,
_generate_mirror_pattern,
_generate_move_face,
_generate_revolve,
_generate_sketch,
_hole_counterbore_depth_mm,
_hole_counterbore_diameter_mm,
_hole_countersink_angle_rad,
_hole_countersink_diameter_mm,
_hole_depth_mm,
_hole_diameter_mm,
_hole_drill_angle_rad,
_hole_has_drill_tip,
_hole_has_through_dimension,
_hole_owned_cut_faces,
_hole_should_use_sw_cut_holes,
_infer_closed_wire_loops,
_linear_pattern_offsets,
_looks_like_reverse_history,
_loop_area_from_radii,
_loop_radius_candidates,
_loops_matching_owned_radii,
_model_offset_to_host_local,
_operation_priority,
_ordered_wire_entities,
_owned_bbox_cut,
_owned_cylindrical_cut_faces,
_owned_extrude_terminal_offsets_mm,
_owned_profile_radii_mm,
_pattern_direction_vector,
_prefer_blind_sketch_extrude,
_project_owned_faces_to_sketch_bbox,
_resolve_extrude_owned_termination,
_reverse_curve_entity,
_revolve_axis_expr,
_sketch_circle_radii_mm,
_sketch_construction_axis,
_sketch_has_buildable_profile,
_sketch_point_to_model_from_basis,
_sw_math_transform_matrix,
_translate_owned_faces,
_translate_sketch_entities,
_translated_operation,
_translated_sketch,
generate_build123d_code,
get_part_name,
sort_operations_for_history,
)
from .runtime_lib import RUNTIME_LIB_LINES
__all__ = [
"RUNTIME_LIB_LINES",
"SW_END_CONDITIONS",
"THROUGH_CUT_AMOUNT_MM",
"analyze_parameterization_status",
"convert_sw_plugin_json_to_ir",
"enrich_rebuild_parameters",
"extract_editable_parameters",
"generate_build123d_code",
"get_part_name",
"normalize_to_ir",
"sort_operations_for_history",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
"""Shared helpers for the SW-IR and code-generation sides of the translator.
These small utilities are used by both ``ir`` (SolidWorks plugin JSON to
backend IR) and ``codegen`` (backend IR to build123d source). Anything used
by exactly one side lives in that side's module instead.
"""
from __future__ import annotations
import math
from typing import Any, Optional
#: SolidWorks numeric end-condition codes, shared by IR conversion and codegen.
SW_END_CONDITIONS = {
0: "Blind",
1: "ThroughAll",
2: "ThroughAllBoth",
3: "UpToVertex",
4: "UpToSurface",
5: "OffsetFromSurface",
6: "ThroughAllAndBlind",
7: "UpToBody",
8: "MidPlane",
9: "ThroughNext",
}
#: Generous through-cut length used when a termination reference is missing.
THROUGH_CUT_AMOUNT_MM = 200
def _tuple3(values: Any) -> tuple[float, float, float]:
values = list(values or [0, 0, 0])
values = (values + [0, 0, 0])[:3]
return tuple(values)
def _point_m_to_mm(point: Any) -> tuple[float, float, float]:
values = list(point or [0, 0, 0])
values = (values + [0, 0, 0])[:3]
return tuple(float(value) * 1000 for value in values)
def _scale_point(point: Any) -> list[float]:
values = [0 if value is None else float(value) for value in (point or [0, 0])]
return [_scale_length(value) for value in values[:2]]
def _scale_length(value: Any) -> float:
value = 0 if value is None else float(value)
return value * 1000 if abs(value) <= 10 else value
def _to_degrees(value: Any) -> float:
value = 0 if value is None else float(value)
return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value
def _unit3(vector: list[Any]) -> list[float]:
raw = [float(vector[i]) for i in range(3)]
length = math.sqrt(sum(v * v for v in raw))
if length <= 0:
return [0.0, 0.0, 0.0]
return [v / length for v in raw]
def _points_bbox(points: list[list[float]]) -> Optional[list[float]]:
if not points:
return None
return [
min(point[0] for point in points),
min(point[1] for point in points),
min(point[2] for point in points),
max(point[0] for point in points),
max(point[1] for point in points),
max(point[2] for point in points),
]
def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None:
if not isinstance(point, list) or len(point) < 2:
return None
return (round(float(point[0]), places), round(float(point[1]), places))
def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]:
z = point[2] if len(point) > 2 else 0
return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits))
def _dedupe_points(points: list[list[float]]) -> list[list[float]]:
result = []
seen = set()
for point in points:
key = _rounded_point_key(point)
if key in seen:
continue
seen.add(key)
result.append(point)
return result
def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool:
return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance
def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool:
return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3))
def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]:
return [
bbox[0] + offset[0],
bbox[1] + offset[1],
bbox[2] + offset[2],
bbox[3] + offset[0],
bbox[4] + offset[1],
bbox[5] + offset[2],
]
def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float:
score = 0.0
for axis in range(3):
score += max(source[axis] - candidate[axis], 0)
score += max(candidate[axis + 3] - source[axis + 3], 0)
return score
def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float:
score = 0.0
for axis in range(3):
source_center = (source[axis] + source[axis + 3]) / 2
candidate_center = (candidate[axis] + candidate[axis + 3]) / 2
axis_size = max(source[axis + 3] - source[axis], 1.0)
score += abs(candidate_center - source_center) / axis_size
return score
def _bbox_area_2d(bbox: Optional[list[float]]) -> float:
if not isinstance(bbox, list) or len(bbox) < 4:
return 0.0
return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1]))
def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool:
if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4:
return False
return (
float(outer[0]) <= float(inner[0]) + tolerance
and float(outer[1]) <= float(inner[1]) + tolerance
and float(outer[2]) >= float(inner[2]) - tolerance
and float(outer[3]) >= float(inner[3]) - tolerance
)
def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float:
if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4:
return 0.0
ix0 = max(float(a[0]), float(b[0]))
iy0 = max(float(a[1]), float(b[1]))
ix1 = min(float(a[2]), float(b[2]))
iy1 = min(float(a[3]), float(b[3]))
intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0)
smaller = min(_bbox_area_2d(a), _bbox_area_2d(b))
if smaller <= 1e-9:
return 0.0
return intersection / smaller
def _loop_bbox(entities: list[dict[str, Any]]) -> Optional[list[float]]:
points = []
for ent in entities:
if not isinstance(ent, dict):
continue
if ent.get("type") == "circle":
center = ent.get("center")
radius = ent.get("radius_mm")
if isinstance(center, list) and len(center) >= 2 and radius is not None:
radius_value = abs(float(radius))
points.append([float(center[0]) - radius_value, float(center[1]) - radius_value])
points.append([float(center[0]) + radius_value, float(center[1]) + radius_value])
continue
for key in ("start", "end", "center"):
point = ent.get(key)
if isinstance(point, list) and len(point) >= 2:
points.append(point)
if not points:
return None
return [
min(float(point[0]) for point in points),
min(float(point[1]) for point in points),
max(float(point[0]) for point in points),
max(float(point[1]) for point in points),
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,949 @@
"""Frozen runtime library embedded into generated build123d scripts.
These lines are appended after the per-model header (imports plus source
volume/area constants) in every generated script. The library is stable:
safe boolean wrappers, selector-based edge matching, and owned-face cutters.
Changes here affect every translator-generated rebuild.
"""
from __future__ import annotations
RUNTIME_LIB_LINES: list[str] = [
"",
"def _dist(a, b):",
" return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))",
"",
"def _owned_face_match_score(shape, expected_faces):",
" if not expected_faces:",
" return 0.0",
" try:",
" available = list(shape.faces())",
" except Exception:",
" return 1e99",
" total = 0.0",
" for expected in expected_faces:",
" bbox_m = expected.get('box_m')",
" if not bbox_m or len(bbox_m) < 6 or not available:",
" total += 1e6",
" continue",
" target_box = [float(v) * 1000 for v in bbox_m[:6]]",
" surface = expected.get('surface') or {}",
" target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')",
" target_area = float(expected.get('area_m2') or 0) * 1_000_000",
" ranked = []",
" for index, face in enumerate(available):",
" try:",
" fb = face.bounding_box()",
" face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]",
" geom = face.geom_type() if callable(face.geom_type) else face.geom_type",
" geom_name = getattr(geom, 'name', str(geom)).lower()",
" type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0",
" bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))",
" area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0",
" ranked.append((type_penalty + bbox_penalty + area_penalty, index))",
" except Exception:",
" continue",
" if not ranked:",
" total += 1e6",
" continue",
" best, index = min(ranked, key=lambda item: item[0])",
" total += best",
" available.pop(index)",
" return total / max(len(expected_faces), 1)",
"",
"def _candidate_score(shape, expected_faces=None):",
" # Owned faces describe this exact SW history step. Final-part mass properties",
" # must not be used to choose an intermediate feature candidate.",
" if expected_faces:",
" return _owned_face_match_score(shape, expected_faces)",
" score = 0",
" if SOURCE_VOLUME_MM3 is not None:",
" try:",
" score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)",
" except Exception:",
" score += 1e99",
" if SOURCE_AREA_MM2 is not None:",
" try:",
" score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01",
" except Exception:",
" score += 1e99",
" score += _owned_face_match_score(shape, expected_faces)",
" return score",
"",
"def _edge_endpoints(edge):",
" vertices = [v.to_tuple() for v in edge.vertices()]",
" if len(vertices) != 2:",
" center = edge.center().to_tuple()",
" return center, center",
" return vertices[0], vertices[1]",
"",
"def _edge_match_score(edge, start, end):",
" a, b = _edge_endpoints(edge)",
" endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))",
" containment_score = edge.distance_to(start) + edge.distance_to(end)",
" return min(endpoint_score, containment_score)",
"",
"def select_edges_by_endpoints(part, selector_points, tolerance=0.5):",
" edges = list(part.edges())",
" selected = []",
" used = set()",
" for selector in selector_points:",
" start, end = selector",
" ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" score, index, edge = ranked[0]",
" if score > tolerance:",
" raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")",
" if index not in used:",
" selected.append(edge)",
" used.add(index)",
" return selected",
"",
"def _bbox_match_score(edge, bbox_mm):",
" if not bbox_mm or len(bbox_mm) < 6:",
" return float('inf')",
" try:",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" mins = tuple(float(bbox_mm[i]) for i in range(3))",
" maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))",
" diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.15)",
" def point_score(point):",
" total = 0.0",
" for axis in range(3):",
" if point[axis] < mins[axis] - pad:",
" total += mins[axis] - pad - point[axis]",
" elif point[axis] > maxs[axis] + pad:",
" total += point[axis] - maxs[axis] - pad",
" return total",
" return min(point_score(mid), (point_score(a) + point_score(b)) / 2)",
" except Exception:",
" return float('inf')",
"",
"def _circle_match_score(edge, circle_params):",
" if not circle_params or len(circle_params) < 7:",
" return float('inf')",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'CIRCLE' not in geom_name:",
" return float('inf')",
" target_center = tuple(float(v) * 1000 for v in circle_params[:3])",
" target_radius = float(circle_params[6]) * 1000",
" edge_center = edge.arc_center.to_tuple()",
" return _dist(edge_center, target_center) + abs(edge.radius - target_radius)",
" except Exception:",
" return float('inf')",
"",
"def _line_match_score(edge, line_params):",
" if not line_params or len(line_params) < 6:",
" return float('inf')",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" return float('inf')",
" target_point = tuple(float(v) * 1000 for v in line_params[:3])",
" target_dir = tuple(float(v) for v in line_params[3:6])",
" a, b = _edge_endpoints(edge)",
" edge_dir_raw = tuple(b[i] - a[i] for i in range(3))",
" length = math.sqrt(sum(v * v for v in edge_dir_raw))",
" if length <= 0:",
" return float('inf')",
" edge_dir = tuple(v / length for v in edge_dir_raw)",
" parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))",
" distance = edge.distance_to(target_point)",
" return distance + parallel * 10",
" except Exception:",
" return float('inf')",
"",
"def select_edges_by_selectors(part, selectors, tolerance=0.5):",
" if part is None:",
" return []",
" edges = list(part.edges())",
" selected = []",
" used = set()",
" for selector in selectors or []:",
" geometry = selector.get('geometry', {})",
" start_vertex = geometry.get('start_vertex')",
" end_vertex = geometry.get('end_vertex')",
" start = start_vertex.get('point_m') if start_vertex else None",
" end = end_vertex.get('point_m') if end_vertex else None",
" bbox_mm = geometry.get('bbox_mm')",
" if start and end:",
" start_mm = tuple(float(v) * 1000 for v in start)",
" end_mm = tuple(float(v) * 1000 for v in end)",
" line_params = geometry.get('curve', {}).get('line_params')",
" if line_params:",
" ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" line_params = geometry.get('curve', {}).get('line_params')",
" circle_params = geometry.get('curve', {}).get('circle_params')",
" if line_params:",
" ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" elif bbox_mm:",
" ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" else:",
" ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])",
" score, index, edge = ranked[0]",
" selector_tolerance = float(selector.get('tolerance_mm') or tolerance)",
" if score > selector_tolerance:",
" # Skip edges that don't match well enough",
" continue",
" if index not in used:",
" selected.append(edge)",
" used.add(index)",
" return selected",
"",
"def _point_inside_bbox(point, bbox_mm, pad=0.25):",
" return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))",
"",
"def fillet_edges_from_owned_surface_bbox(part, selectors):",
" if part is None:",
" return []",
" boxes = []",
" seen_boxes = set()",
" for selector in selectors or []:",
" if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):",
" continue",
" bbox = (selector.get('geometry') or {}).get('bbox_mm')",
" if bbox and len(bbox) >= 6:",
" normalized = [float(v) for v in bbox[:6]]",
" key = tuple(round(v, 6) for v in normalized)",
" if key not in seen_boxes:",
" seen_boxes.add(key)",
" boxes.append(normalized)",
" if len(boxes) < 2:",
" return []",
" selected = []",
" used_keys = set()",
" for box in boxes:",
" diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.08)",
" sizes = [abs(box[i + 3] - box[i]) for i in range(3)]",
" thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]",
" circle_candidates = []",
" if thin_axes:",
" thin_axis = thin_axes[0]",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'CIRCLE' not in geom_name:",
" continue",
" eb = edge.bounding_box()",
" edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]",
" ok = True",
" score = 0.0",
" for axis in range(3):",
" if axis == thin_axis:",
" plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))",
" if plane_delta > pad:",
" ok = False",
" break",
" score += plane_delta",
" else:",
" if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:",
" ok = False",
" break",
" score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])",
" if not ok:",
" continue",
" key = tuple(round(v, 5) for v in edge_box)",
" circle_candidates.append((score, key, edge))",
" except Exception:",
" continue",
" if circle_candidates:",
" circle_candidates.sort(key=lambda item: item[0])",
" for _, key, edge in circle_candidates:",
" if key in used_keys:",
" continue",
" used_keys.add(key)",
" selected.append(edge)",
" break",
" continue",
" box_candidates = []",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" continue",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):",
" continue",
" key = tuple(round(v, 5) for point in (a, b) for v in point)",
" box_candidates.append((float(edge.length), key, edge))",
" except Exception:",
" continue",
" if not box_candidates:",
" continue",
" box_candidates.sort(key=lambda item: item[0], reverse=True)",
" for _, key, edge in box_candidates:",
" reverse_key = key[3:] + key[:3]",
" if key in used_keys or reverse_key in used_keys:",
" continue",
" used_keys.add(key)",
" selected.append(edge)",
" break",
" if selected:",
" return selected",
" union_bbox = [",
" min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)",
" for i in range(6)",
" ]",
" diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))",
" pad = max(0.25, diag * 0.05)",
" candidates = []",
" for edge in part.edges():",
" try:",
" geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type",
" geom_name = getattr(geom_type, 'name', str(geom_type))",
" if 'LINE' not in geom_name:",
" continue",
" a, b = _edge_endpoints(edge)",
" mid = tuple((a[i] + b[i]) / 2 for i in range(3))",
" if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):",
" continue",
" candidates.append((float(edge.length), edge))",
" except Exception:",
" continue",
" if not candidates:",
" return []",
" candidates.sort(key=lambda item: item[0], reverse=True)",
" return [candidates[0][1]]",
"",
"def fillet_with_tolerance(edges, radius):",
" radii = [float(radius)]",
" shrink = max(0.001, abs(float(radius)) * 0.001)",
" if float(radius) > shrink:",
" radii.append(float(radius) - shrink)",
" radii.append(float(radius) * 0.99)",
" last_error = None",
" for candidate_radius in radii:",
" if candidate_radius <= 0:",
" continue",
" try:",
" return fillet(edges, radius=candidate_radius)",
" except Exception as exc:",
" last_error = exc",
" continue",
" if last_error:",
" raise last_error",
" return fillet(edges, radius=radius)",
"",
"def fillet_selected(part, radius, selectors, owned_faces=None):",
" if part is None:",
" return part",
" if not selectors:",
" # No edge selectors - skip fillet to avoid failing on all edges",
" return part",
" candidates = []",
" owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)",
" if owned_edges:",
" try:",
" candidates.append(fillet_with_tolerance(owned_edges, radius))",
" except Exception:",
" pass",
" try:",
" target_edges = select_edges_by_selectors(part, selectors)",
" if target_edges:",
" candidates.append(fillet_with_tolerance(target_edges, radius))",
" except Exception:",
" pass",
" result = part",
" applied_any = False",
" for selector in selectors:",
" edges = select_edges_by_selectors(result, [selector])",
" if not edges:",
" continue # Skip selectors that don't match any edge",
" try:",
" result = fillet_with_tolerance([edges[0]], radius)",
" applied_any = True",
" except Exception:",
" # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.",
" continue",
" if applied_any:",
" candidates.append(result)",
" variants = []",
" for selector in selectors:",
" edges = select_edges_by_selectors(part, [selector])",
" if not edges:",
" continue",
" try:",
" variants.append(fillet_with_tolerance([edges[0]], radius))",
" except Exception:",
" continue",
" if variants:",
" try:",
" union_result = part",
" for variant in variants:",
" union_result = union_result + variant",
" candidates.append(union_result)",
" except Exception:",
" pass",
" try:",
" intersection_result = part",
" for variant in variants:",
" intersection_result = intersection_result & variant",
" candidates.append(intersection_result)",
" except Exception:",
" pass",
" if candidates:",
" return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]",
" return part",
"",
"def chamfer_selected(part, distance, selectors, owned_faces=None):",
" if part is None:",
" return part",
" if not selectors:",
" # No edge selectors available - chamfer would fail on all edges",
" return part",
" candidates = []",
" owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)",
" if owned_edges:",
" try:",
" candidates.append(chamfer(owned_edges, length=distance))",
" except Exception:",
" pass",
" target_edges = select_edges_by_selectors(part, selectors)",
" if target_edges:",
" try:",
" candidates.append(chamfer(target_edges, length=distance))",
" except Exception:",
" pass",
" result = part",
" applied_any = False",
" for selector in selectors:",
" edges = select_edges_by_selectors(result, [selector])",
" if not edges:",
" continue",
" try:",
" result = chamfer([edges[0]], length=distance)",
" applied_any = True",
" except Exception:",
" continue",
" if applied_any:",
" candidates.append(result)",
" if candidates:",
" return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]",
" return part",
"",
"def is_internal_cone_face(face, part):",
" try:",
" bbox_m = face.get('box_m')",
" surface = face.get('surface') or {}",
" if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):",
" return False",
" params = surface.get('cone_params')",
" if not params or len(params) < 6:",
" return False",
" direction = tuple(float(v) for v in params[3:6])",
" axis = max(range(3), key=lambda i: abs(direction[i]))",
" radial_axes = tuple(i for i in range(3) if i != axis)",
" part_bbox = part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" tol = 0.25",
" touches_outer = any(",
" abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol",
" for i in radial_axes",
" )",
" return not touches_outer",
" except Exception:",
" return False",
"",
"def is_external_cone_face(face, part):",
" try:",
" bbox_m = face.get('box_m')",
" surface = face.get('surface') or {}",
" if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):",
" return False",
" params = surface.get('cone_params')",
" if not params or len(params) < 6:",
" return False",
" direction = tuple(float(v) for v in params[3:6])",
" axis = max(range(3), key=lambda i: abs(direction[i]))",
" radial_axes = tuple(i for i in range(3) if i != axis)",
" part_bbox = part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" tol = 0.25",
" return any(",
" abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol",
" for i in radial_axes",
" )",
" except Exception:",
" return False",
"",
"def make_owned_external_cone_chamfer_cutter(face):",
" surface = face.get('surface') or {}",
" params = surface.get('cone_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" base_radius = abs(float(params[6]) * 1000)",
" half_angle = abs(float(params[7]))",
" if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" delta = (x - origin[0], y - origin[1], z - origin[2])",
" axial = sum(delta[i] * direction[i] for i in range(3))",
" projections.append(axial)",
" start = min(projections)",
" end = max(projections)",
" height = max(0.001, end - start)",
" r1 = max(0.0, base_radius - math.tan(half_angle) * start)",
" r2 = max(0.0, base_radius - math.tan(half_angle) * end)",
" outer_radius = max(r1, r2) + 0.001",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" if r1 <= 1e-9:",
" r1 = 1e-6",
" if r2 <= 1e-9:",
" r2 = 1e-6",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)",
" return cutter_part.part",
"",
"def chamfer_owned_external_cones(part, faces):",
" if part is None:",
" return part, False",
" result = part",
" applied = False",
" for face in faces or []:",
" if not is_external_cone_face(face, result):",
" continue",
" cutter = make_owned_external_cone_chamfer_cutter(face)",
" new_result = safe_subtract(result, cutter)",
" if new_result is not result:",
" result = new_result",
" applied = True",
" return result, applied",
"",
"def chamfer_owned_internal_cones(part, faces):",
" if part is None:",
" return part, False",
" result = part",
" applied = False",
" for face in faces or []:",
" if not is_internal_cone_face(face, result):",
" continue",
" cutter = make_owned_cone_cutter(face)",
" new_result = safe_subtract(result, cutter)",
" if new_result is not result:",
" result = new_result",
" applied = True",
" return result, applied",
"",
"def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):",
" cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]",
" if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):",
" result, applied = chamfer_owned_internal_cones(part, cone_faces)",
" if applied:",
" return result",
" if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):",
" result, applied = chamfer_owned_external_cones(part, cone_faces)",
" if applied:",
" return result",
" return chamfer_selected(part, distance, selectors, owned_faces)",
"",
"def safe_subtract(part, cutter):",
" if part is None or cutter is None:",
" return part",
" try:",
" vol_before = float(part.volume)",
" except Exception:",
" vol_before = -1",
" try:",
" cut = part - cutter",
" if cut is None:",
" print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')",
" return part",
" # Accept the cut even when solids() reports 0 – can happen",
" # for valid boolean results with non-standard structures.",
" try:",
" nb_solids = len(list(cut.solids()))",
" if nb_solids == 0:",
" print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')",
" except Exception:",
" pass",
" return cut",
" except Exception as e:",
" print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')",
" return part",
"",
"def _project_bbox_along_direction(bbox, origin, direction):",
" mins = tuple(float(bbox[i]) for i in range(3))",
" maxs = tuple(float(bbox[i + 3]) for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))",
" return min(projections), max(projections)",
"",
"def make_owned_cylinder_cutter(face, target_part=None):",
" surface = face.get('surface') or {}",
" params = surface.get('cylinder_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" radius = abs(float(params[6]) * 1000)",
" if norm <= 1e-9 or radius <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)",
" if target_part is not None:",
" try:",
" part_bbox = target_part.bounding_box()",
" part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())",
" part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)",
" through_tolerance = max(1.0, radius * 0.12)",
" if abs(start - part_start) <= through_tolerance:",
" start = part_start",
" if abs(end - part_end) <= through_tolerance:",
" end = part_end",
" except Exception:",
" pass",
" height = max(0.001, end - start)",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" return cutter_part.part",
"",
"def make_owned_cone_cutter(face):",
" surface = face.get('surface') or {}",
" params = surface.get('cone_params')",
" bbox_m = face.get('box_m')",
" if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" base_radius = abs(float(params[6]) * 1000)",
" half_angle = abs(float(params[7]))",
" if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" projections = []",
" for x in (mins[0], maxs[0]):",
" for y in (mins[1], maxs[1]):",
" for z in (mins[2], maxs[2]):",
" projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))",
" start = min(projections)",
" end = max(projections)",
" # Keep a tiny overlap for the boolean while preserving blind-hole depth.",
" height = max(0.001, end - start) + 0.001",
" # SolidWorks ConeParams stores the radius at the cone origin; along the axis",
" # direction the radius tapers rather than expands for hole drill tips.",
" r1 = max(0.0, base_radius - math.tan(half_angle) * start)",
" r2 = max(0.0, base_radius - math.tan(half_angle) * end)",
" if max(r1, r2) <= 1e-9:",
" return None",
" if r1 <= 1e-9:",
" r1 = 1e-6",
" if r2 <= 1e-9:",
" r2 = 1e-6",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" return cutter_part.part",
"",
"def make_owned_face_cutter(face, target_part=None):",
" surface = face.get('surface') or {}",
" if surface.get('is_cylinder'):",
" return make_owned_cylinder_cutter(face, target_part)",
" if surface.get('is_cone'):",
" return make_owned_cone_cutter(face)",
" return None",
"",
"def cut_owned_cylindrical_faces(part, faces):",
" result = part",
" for face in faces or []:",
" cutter = make_owned_face_cutter(face, result)",
" result = safe_subtract(result, cutter)",
" return result",
"",
"def make_owned_flip_side_ring_cutter(face, target_part):",
" surface = face.get('surface') or {}",
" params = surface.get('cylinder_params')",
" bbox_m = face.get('box_m')",
" if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:",
" return None",
" origin = tuple(float(v) * 1000 for v in params[:3])",
" direction = tuple(float(v) for v in params[3:6])",
" norm = math.sqrt(sum(v * v for v in direction))",
" inner_radius = abs(float(params[6]) * 1000)",
" if norm <= 1e-9 or inner_radius <= 1e-9:",
" return None",
" direction = tuple(v / norm for v in direction)",
" mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))",
" maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))",
" start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)",
" height = max(0.001, end - start)",
" center_offset = (start + end) / 2",
" center = tuple(origin[i] + direction[i] * center_offset for i in range(3))",
" try:",
" part_bbox = target_part.bounding_box()",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" radial = []",
" for x in (part_min[0], part_max[0]):",
" for y in (part_min[1], part_max[1]):",
" for z in (part_min[2], part_max[2]):",
" delta = (x - origin[0], y - origin[1], z - origin[2])",
" axial = sum(delta[i] * direction[i] for i in range(3))",
" perp = tuple(delta[i] - axial * direction[i] for i in range(3))",
" radial.append(math.sqrt(sum(v * v for v in perp)))",
" outer_radius = max(radial) + max(1.0, inner_radius * 0.05)",
" except Exception:",
" outer_radius = inner_radius + 100.0",
" if outer_radius <= inner_radius + 1e-6:",
" return None",
" with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:",
" Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))",
" Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)",
" return cutter_part.part",
"",
"def cut_owned_flip_side_cylindrical_faces(part, faces):",
" result = part",
" for face in faces or []:",
" cutter = make_owned_flip_side_ring_cutter(face, result)",
" result = safe_subtract(result, cutter)",
" return result",
"",
"def cut_owned_bbox(part, bbox_mm):",
" if part is None or not bbox_mm or len(bbox_mm) < 6:",
" return part",
" mins = tuple(float(bbox_mm[i]) for i in range(3))",
" maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))",
" size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))",
" center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))",
" cutter = Pos(center) * Box(size[0], size[1], size[2])",
" return safe_subtract(part, cutter)",
"",
"def shape_face_count(shape):",
" if shape is None:",
" return 0",
" try:",
" return len(list(shape.faces()))",
" except Exception:",
" return 0",
"",
"def safe_union(part, solid, preserve_visible=False):",
" if part is None:",
" return solid",
" if solid is None:",
" return part",
" try:",
" fused = part + solid",
" # OCCT fuse succeeded; always return the fused result.",
" # is_valid() can return False for edge cases where the geometry",
" # is actually correct (e.g. touching-at-faces). Accept it.",
" return fused",
" except Exception as e:",
" print(f' UNION: fuse threw {type(e).__name__}: {e}')",
" pass",
" try:",
" compound = Compound.make_composite([part, solid])",
" fused = compound.fuse()",
" try:",
" if len(list(fused.solids())) > 0:",
" print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')",
" return fused",
" except Exception:",
" pass",
" except Exception as e:",
" print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')",
" pass",
" shapes = []",
" try:",
" shapes.extend(list(part.solids()))",
" except Exception:",
" shapes.append(part)",
" try:",
" shapes.extend(list(solid.solids()))",
" except Exception:",
" shapes.append(solid)",
" return Compound.make_composite(shapes)",
"",
"def sw_inverted_profile_cut(part, profile_solid, normal):",
" if part is None or profile_solid is None:",
" return part",
" try:",
" part_bbox = part.bounding_box()",
" profile_bbox = profile_solid.bounding_box()",
" n = tuple(float(v) for v in normal)",
" axis = max(range(3), key=lambda i: abs(n[i]))",
" part_min = part_bbox.min.to_tuple()",
" part_max = part_bbox.max.to_tuple()",
" prof_min = profile_bbox.min.to_tuple()",
" prof_max = profile_bbox.max.to_tuple()",
" margin = 5.0",
" mins = [part_min[i] - margin for i in range(3)]",
" maxs = [part_max[i] + margin for i in range(3)]",
" mins[axis] = prof_min[axis] - margin * 0.05",
" maxs[axis] = prof_max[axis] + margin * 0.05",
" center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))",
" size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))",
" envelope = Pos(center) * Box(size[0], size[1], size[2])",
" outside_profile = safe_subtract(envelope, profile_solid)",
" return safe_subtract(part, outside_profile)",
" except Exception:",
" return part",
"",
"def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):",
" part = sw_inverted_profile_cut(part, profile_solid, normal)",
" if part is None or profile_solid is None:",
" return part",
" try:",
" outer_radius = abs(float(outer_radius_mm))",
" inner_radius = abs(float(inner_radius_mm))",
" except Exception:",
" return part",
" if outer_radius <= inner_radius + 1e-6:",
" return part",
" try:",
" profile_bbox = profile_solid.bounding_box()",
" prof_min = profile_bbox.min.to_tuple()",
" prof_max = profile_bbox.max.to_tuple()",
" center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))",
" n = tuple(float(v) for v in normal)",
" axis = max(range(3), key=lambda i: abs(n[i]))",
" span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])",
" margin_xy = max(2.0, span_xy * 0.05)",
" margin_z = 0.1",
" size = tuple(",
" max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))",
" for i in range(3)",
" )",
" plane = Plane(",
" origin=center,",
" x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),",
" z_dir=n,",
" )",
" cut_extent = prof_max[axis] - prof_min[axis]",
" cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)",
" with BuildSketch(plane) as ring_sketch:",
" Circle(outer_radius)",
" Circle(inner_radius, mode=Mode.SUBTRACT)",
" ring = extrude(ring_sketch.sketch, amount=cut_amount)",
" return safe_union(part, ring)",
" except Exception:",
" return part",
"",
"def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):",
" if part is None:",
" return part",
" if not positions or diameter <= 0 or depth <= 0:",
" return part",
" plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]",
" frame = host_face.get('frame') or {}",
" normal = tuple(float(v) for v in plane[:3])",
" plane_point = tuple(float(v) * 1000 for v in plane[3:6])",
" origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))",
" x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))",
" y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))",
" has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0",
" bbox = part.bounding_box()",
" part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))",
" toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))",
" dot = sum(toward_center[i] * normal[i] for i in range(3))",
" inward = normal if dot >= 0 else tuple(-v for v in normal)",
" axis = max(range(3), key=lambda i: abs(inward[i]))",
" rotation = (0, 0, 0)",
" if axis == 0:",
" rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)",
" elif axis == 1:",
" rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)",
" elif inward[2] < 0:",
" rotation = (180, 0, 0)",
" tip_depth = 0",
" if include_drill_tip and drill_angle > 0:",
" tip_depth = (diameter / 2) / math.tan(drill_angle / 2)",
" countersink_depth = 0",
" if countersink_diameter > diameter and countersink_angle > 0:",
" countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)",
" result = part",
" for pos in positions:",
" x, y = float(pos[0]), float(pos[1])",
" if has_frame:",
" start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))",
" elif axis == 0:",
" start = (plane_point[0], x, y)",
" elif axis == 1:",
" start = (x, plane_point[1], -y)",
" else:",
" start = (x, y, plane_point[2])",
" cut_depth = depth",
" if depth >= 199:",
" part_min = bbox.min.to_tuple()",
" part_max = bbox.max.to_tuple()",
" corners = []",
" for ci in range(2):",
" for cj in range(2):",
" for ck in range(2):",
" corners.append((",
" part_min[0] if ci else part_max[0],",
" part_min[1] if cj else part_max[1],",
" part_min[2] if ck else part_max[2],",
" ))",
" cut_depth = max(",
" sum((corner[i] - start[i]) * inward[i] for i in range(3))",
" for corner in corners",
" ) + 2.0",
" cutters = []",
" cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0",
" cs_depth = countersink_depth if countersink_depth > 0 else 0",
" hole_start = cs_depth",
" hole_depth = max(0.001, cut_depth - hole_start - cb_depth)",
" if hole_depth > 0:",
" hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))",
" cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))",
" if cb_depth > 0:",
" cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))",
" cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))",
" if cs_depth > 0:",
" cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))",
" cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)",
" cutters.append(cs)",
" if tip_depth > 0:",
" base = tuple(start[i] + inward[i] * cut_depth for i in range(3))",
" tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))",
" tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)",
" cutters.append(tip)",
" if len(cutters) == 1:",
" cutter = cutters[0]",
" else:",
" cutter = Compound.make_composite(cutters)",
" result = safe_subtract(result, cutter)",
" return result",
"",
]
File diff suppressed because it is too large Load Diff
+22
View File
@@ -60,6 +60,28 @@ class ProfileSchemaTests(unittest.TestCase):
)
self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"]))
def test_runtime_capability_flags_are_complete_and_structurally_sound(self) -> None:
contracts = self.schema["operation_contracts"]
flags = ("body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok")
for atomic_id, contract in contracts.items():
capability = contract.get("runtime_capability")
self.assertIsInstance(capability, dict, atomic_id)
for flag in flags:
self.assertIsInstance(capability.get(flag), bool, f"{atomic_id}.{flag}")
for atomic_id, contract in contracts.items():
capability = contract["runtime_capability"]
if capability["replayable"]:
# Only body-mutating features (or pattern replay itself) can be
# the source of another pattern replay.
self.assertTrue(
capability["body_mutating"] or atomic_id.startswith("pattern_"),
atomic_id,
)
if capability["requires_active_body"]:
self.assertTrue(capability["body_mutating"], atomic_id)
if capability["open_profile_ok"]:
self.assertTrue(atomic_id.startswith("extrude_cut"), atomic_id)
def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
@@ -0,0 +1,514 @@
# CADFS 全量能力闭环目标
## 目标
将 CADFS FeatureScript 的完整建模历史通用地转换为 CDSL,再由 CDSL engine
重建 STEP。目标不是让少数代表模型通过,也不是为某个样本拟合最终 STEP;目标是
补齐当前 CADFS 全量语料实际出现的所有建模语义,使每个可比较模型都能以完整
feature history 生成工程相似的 STEP。
工程验收使用 `comparison.json.rp.passed == true`:双向 surface max/p99 与 bbox
不超过 `0.02 mm`、体积和面积相对误差不超过 `0.005`、实体数相等。`strict` 的
`0.01 mm` / `1e-5` 阈值保留为高精度诊断,不能因严格失败而隐藏已工程相似的模型,
也不能因生成 STEP 就把几何错误的模型标为成功。
本文件的范围是
`output-history/20260907-235209` 的 9,347 条历史快照。后续重新扫描全量数据时,
必须以新快照的真实样本数和能力矩阵更新本文件及本地能力台账。
## 不可妥协的实现原则
这是绘图引擎和通用 converter,不是样本修复脚本。每项能力必须由 FeatureScript
语义、CDSL contract 和内核拓扑共同定义,并对同类输入普遍成立。
- 禁止以 `sample_id`、文件路径、特定尺寸、特定坐标、特定 feature ID 或 gold STEP
测量值驱动行为。不得为让单条样本通过而植入分支、偏移量、默认 profile 或 selector。
- 不得读取原 STEP 来补齐 FeatureScript 未提供的参数、反推 CDSL 尺寸或决定建模策略。
原 STEP 只用于最终比较、诊断和已验证的 source exception 证据。
- 不得把未知语义降级成任意盲拉伸、默认 union、任意当前 body、任意相近 face/edge
或静默跳过。无法唯一表达或绑定时必须保留可执行前缀、输出明确诊断,并记录能力
缺口。
- 优先扩展显式 CDSL schema、typed runtime state、body graph 和 kernel-level topology
delta;不得以 parsing/lowering 层的临时重写替代应由 runtime/adapter 承担的几何语义。
- 允许针对几何类别采用受限算法,但适用前提必须由通用的、可验证的几何条件表达,
并有正向、反向和边界测试。条件不满足时必须拒绝并诊断,不得猜测。
- 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制,
必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小
原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。
- 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、
runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少
任一层只能标记为“部分完成”。
## 全量基线
历史快照的主要操作和当前能力缺口如下。数量是出现次数或受该缺口阻塞的模型数,
只用于排序,不代表一个操作只有一种语义。
| FeatureScript 操作 | 历史出现次数 | 当前主要缺口数 |
| --- | ---: | ---: |
| `newSketch` | 20,857 | 草图/工作平面诊断 7,043 |
| `extrude` | 18,168 | 3,600 |
| `fillet` | 5,210 | 1,904 |
| `revolve` | 2,087 | 662 |
| `chamfer` | 1,818 | 588 |
| `cPlane` | 1,620 | 168 |
| `hole` | 1,203 | 582 |
| `shell` | 729 | 696 |
| `mirror` | 483 | 241 |
| `transform` | 468 | 252 |
| `sweep` | 378 | 326 |
| `loft` | 377 | 107 |
| `circularPattern` | 252 | 139 |
| `booleanBodies` | 228 | 187 |
已发现的派生 profile / topology 语义必须单独覆盖,不能合并为笼统的 `extrude`
或 `loft` 支持:
| 语义 | 历史缺口数 |
| --- | ---: |
| `extrude_profile_topology:intersect` | 383 |
| `extrude_profile_topology:cap_face` | 201 |
| `extrude_profile_topology:cap_edge` | 134 |
| `extrude_profile_topology:swept_face` | 99 |
| `extrude_extent:up_to_surface` | 100 |
| `loft_profile_topology:cap_face` | 30 |
| `extrude_profile_topology:offset_face` | 18 |
| `extrude_profile_topology:swept_edge` | 16 |
| `loft_profile_topology:swept_face` | 9 |
| `extrude_extent:up_to_vertex` | 7 |
| `extrude_surface_or_mixed` | 4 |
| `extrude_extent:up_to_body` | 2 |
| `extrude_profile_topology:mid_cap_edge` | 2 |
| `loft_profile_topology:cap_edge` / `cap_vertex` | 1 / 1 |
历史报告还记录了 1,335 个未支持操作诊断。当前已知的 P2 操作包括 `draft`、
`thicken`、`split`、`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、
`derive`、`import` 和 `bend_add`。重新扫描时必须从诊断原文生成完整操作清单;
未列出的新操作不能被静默归入已有能力。
## 覆盖模型
每一项能力的回归选择必须覆盖下面五个维度,而不是只覆盖操作名称:
1. 操作和参数:operation mode、实体/曲面模式、extent、方向、offset、draft、
角度、数量、终止条件和 result mode。
2. 几何输入:直线、圆弧、圆、椭圆、B-spline、开口/闭合 wire、多 region、孔洞、
退化和自交拒绝路径。
3. 拓扑来源:原草图、`CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、`SWEPT_EDGE`、
`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`、`COPY` 和 boolean 后继。
4. body 生命周期:`NEW`、`ADD`、`REMOVE`、`INTERSECT`、keep tools、copy、
transform、delete、mirror、linear/circular/nested pattern、多实体输出。
5. 比较状态:strict、RP、实际几何拒绝、conversion defer、runtime ineligible、
rebuild failure、comparison timeout 与 source exception。
维护三个固定层级的回归集:
| 层级 | 用途 | 通过条件 |
| --- | --- | --- |
| 原子语义矩阵 | 每个 operation/参数/拓扑/body tuple 的最小合成模型 | CDSL contract、可执行性和精确几何断言。 |
| 核心 17 | 跨能力的稳定烟雾回归 | 完整 history、工程相似或有归因明确的 source/comparison 证据。 |
| 扩展 100+ | 每个已出现 tuple 至少多个真实语料样本 | RP 工程相似率、失败诊断和能力覆盖均可追溯。 |
| 全量 9,347 | 完整发布前的能力验证 | 每条都有完整分类;支持的完整 history 均工程相似,剩余项仅能是有证据的 source exception 或未实现 capability。 |
扩展集必须从能力矩阵自动选择并保留历史失败样本。修复能力后应扩大相应 tuple 的
样本数;不得因当前失败而从 manifest 中移除样本,也不得以新的贪心选择替换已有
反例。
## 实施路线
### 开源实现策略
每个新的内核级能力在自研前,先检查官方 OCCT/OCP API、项目现有依赖和成熟开源实现。
本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`:其
`topology/tracking.py` 保留 OCC builder 的 `Modified`、`Generated`、`IsDeleted` 和
section-edge 历史,`ocp_booleans.py` 展示了保留 history 的 boolean 与 same-domain
cleanup,其他 kernel/operators/tests 覆盖 transform、shell、sweep、loft、fillet 和
chamfer。这些实现对 P0 topology delta、body provenance 和 P2 feature adapter 具有
直接参考价值。
采用外部方案时必须确认许可证、OCP/OCCT 版本和异常语义,并将算法适配到本项目的
CDSL contract、body graph 和 selector resolver。SimpleCADAPI 强制单一 `Solid` 的
boolean helper 不能直接使用,因为 CADFS 需要独立 body、copy、keep-tools 和 pattern
instance 生命周期。每项采用或拒绝外部方案的决定、理由和回归证据都记录在本地能力
台账;不得不经评估地复制代码,或为复用外部 API 破坏现有 contract。
### P0:通用几何状态和拓扑基础
1. 建立显式 body graph:feature 输出 body ID、result mode、copy、transform、delete、
keep tools、pattern instance 和多 body 聚合语义。当前可用的受限增量是以 source
feature body 为输入的 rigid translation/rotation 与受限 non-rigid uniform-scale
`transform_bodies` / `delete_bodies` contract;它不能
代替 fused body 或 pattern instance 的完整 provenance,后两者在唯一性未证明时
必须诊断而不能退化为 current body。capability preflight 已镜像 runtime 的显式
body-member 生命周期:fused/dress-up 输出、boolean 消耗、delete 和 pattern replay
会撤销旧 member 的可选资格;只有 `new_body`、`keep_tools`、明确 transform 输出,或
经 sole-body lineage 已证明的 current successor,才能被后续 body query 选中。受限的
`pattern_circular` 现在可对所有 source 都是明确可选 member 的 `NEW` additive body,或
一个 direct `SWEPT_BODY` 经该 sole-body successor 保留的 current member,以
`pattern_feature_id` / `source_feature_id` / `instance_index` 记录 direct COPY
member;后续 `transform_bodies` 可精确引用该 COPY 而无需写入 runtime ID。CADFS 的
direct `SWEPT_BODY` transform 现可将 `TRANSLATION_3D`、rotation,以及单一线性
sketch/`CAP_EDGE` 或有 physical cap frame 的 direct `CAP_FACE` direction 的非负
`TRANSLATION_DISTANCE` lower 为同一刚体 contract。系统 datum plane、已显式 lower 的
reference plane,以及能由 direct planar source 唯一构成 frame 的 `SWEPT_FACE` 也可提供该
direction;圆柱/曲面 `SWEPT_FACE`、`SWEPT_EDGE`、OFFSET/COPY face 和没有可证明 frame 的
plane query 继续诊断而不猜测。`SCALE_UNIFORMLY` 在 factor 有限且为
正数、scale point 是 Origin、direct sketch vertex(含 circle center)或 direct
`CAP_VERTEX` 时 lower 为显式 `uniform_scale` body transform,不烘焙回草图。`TRANSLATION_ENTITY` 在 direct source
仍是可选独立 body 时接受一个线性
sketch/`CAP_EDGE` 的端点差或两个 sketch/`CAP_VERTEX` 的有序点差;move 的 bake path
另要求 source 是紧邻的 `NEW` body。对于 exact single-source `makeCopy` transform,
后续 `COPY(instanceName=1)` body/line/vertex 可递归验证其 `derivedFrom` 链并引用已生成
body member;对于前序 direct multi-source `makeCopy`,CDSL 以
`transform_copy_refs(transform_feature_id, source_feature_id)` 表示被查询的唯一直接 source,
runtime 分别物化各 source 的 transformed member,aggregate 只用于 STEP 导出且不伪造
topology owner/delta。schema、semantic validation、capability preflight 和 runtime 都要求
owner 是 preceding multi-source `make_copy:true` transform,且 source 是该 transform 的
直接选择项;single-source、source 外、fused、pattern 和无源限定的 aggregate reference
一律拒绝。无位移的 direct `TransformType.COPY` 以显式 zero translation 与
`make_copy:true` 产生一个独立 identity body member,绝不将其写成 non-copy successor
alias。CAP edge/vertex 的 source-local in-plane coordinates 保持 profile frame,
仅映射到 selected physical cap origin。face、
generic face、swept/offset、pattern 或未证明 COPY/curve direction、多个 direction、已吸收 body 与其它非刚体
transform 仍必须明确诊断。对于已证明只有一个 CADFS body 的受限 lineage,direct
`SWEPT_BODY` 可从初始 `new_body` 经 ordinary fusing add、单 body fillet/chamfer/shell
与 exact non-copy transform 续接到当前 CDSL member;cut 保留所选 member,因而不虚构
一个 cut successor。直接 circular ADD 且 source 唯一等于该 current member 时保留这份
证明,并以该成员的实际 B-rep 物化每个 COPY instance;第二个 `new_body`、`make_copy`
transform、boolean、其它 pattern、delete 或任何未列出的 body atomic 都清空它。它绝不以
aggregate/current body 兜底,
也不为 linear/nested pattern、被排除 instance 或无明确 source member 建立 ownership。
唯一新增的
mirror 例外是:直接 `pattern_mirror` 的每个 source 都仍为可选的
`result_mode: new_body` member 时,runtime 以实际 B-rep mirror 物化其唯一的
`instance_index: 1` COPY member;CDSL semantic validation、capability preflight 与
runtime 都要求这一完整条件。普通 additive/cut/dress-up 后继、已吸收 source、linear
或 nested mirror 仍走 feature replay,不能借该例外暴露 aggregate member。
本轮 327 条 transform shard 中 `00120430`、`00496729`、`00749755` 因该 provenance
rule 完整 lower;只有 source 在 mirror 前未被后续 mutation 吸收的 `00749755` 可执行到
RP `rebuilt_approximate`,另两条保留 `runtime_ineligible` 和已有前缀/诊断,绝不以
converter 完整状态冒充 runtime 或相似通过。新增的 multi-source COPY path 令 `00699847`
的 F3 两源 copy 和 F4 的 source-qualified F3/F1、F3/F2 queries 完整 lower,强制
pipeline 为 `rebuilt_strict`;`00950564` 证明 F1 的 direct `SWEPT_BODY` 在 F3/F5/F7/F9
ordinary add 和 F10/F11 chamfer 后精确 lower 为 F12 的 `source_feature_ids:["f_F11"]`,
不引用 current aggregate。`output/single-body-successor-20260909` 的完整 history 为
`converted_complete`、runtime eligible、8/8 feature executed、`rebuilt_approximate`;
RP 通过(bbox delta `0`,体积/面积相对误差 `0.00011913` / `0.00016132`,实体数 `1`)。
strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖
copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member
chain 仍属未完成 contract,继续保留明确诊断。
2. 在 adapter 层记录每次建模操作的 `preserved`、`modified`、`generated`、`deleted`
topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector
绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体
`transform_bodies`(含 OCP `gp_Trsf.SetScale` 的 uniform scale)、单实体
`boolean_bodies` 的 union/subtract/intersect 以及 direct
single-body fillet/chamfer,以及单一 selected solid 的 `shell`:adapter 返回 opaque
kernel relation,registry 只在 source/result snapshot 的 exact relation 唯一时传递
owner 和 successor,并在 rebuild 输出 `topology_deltas`。shell 沿既有
`BRepOffsetAPI_MakeThickSolidByJoin` builder 的 `Modified`、`Generated`、`IsDeleted`
历史记录 delta;未返回 `IsDeleted` 的内核输入不会被虚构为 deleted。该受限 shell path
还记录可由 source subshape 和 kernel event 证明的 `shell.body_face`、
`shell.offset_face`、`shell.closing_descendant` 和 `shell.wall` evidence role。当前
`shell.offset_face` 只有一个受限 lowering consumer:紧邻 shell 后续的 shell face query,
且 FeatureScript 以 `TDD`/`trueDependencyDisambiguation` 明确指向前序 direct `new_body`
blind-prism 的 `extrude.start` 或 `extrude.end`。CDSL 以
`output_role_source(owner_feature_id, output_role)` 保留此关系;registry 只接受 exact
kernel-history relation 的唯一 active snapshot,不能以相同 geometry、bare offset role 或
任意嵌套 CAP query 替代。它仍不是可 lower 的通用 CAP/SWEPT/OFFSET query。多个 Compound 成员时只有被选中实体的同一
builder 可以产生 delta,其他未改成员继续依赖 body graph 的精确等价。多实体 boolean、
非 direct-builder dress-up fallback、复杂 sweep/loft history 及 CAP/SWEPT/section
query roles 仍属未完成范围。受限的单闭合无内环 profile、无 guide/transition 的 direct
solid sweep 在 initial 或 `new_body` final member 上,会记录
`BRepOffsetAPI_MakePipeShell.FirstShape/LastShape` 的 `sweep.start`/`sweep.end`
evidence;受限的 initial closed-wire solid loft 会记录
`BRepOffsetAPI_ThruSections.FirstShape/LastShape` 的 `loft.start`/`loft.end` evidence。
后续 fuse 会重建 topology,因此不复用已经失效的 sweep/loft builder history。当前
CDSL `selectorRef.output_role` 现公开 direct-prism `extrude.start`/`extrude.end` 与四个
sweep/loft direct-builder cap role;registry 只会在
relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`,resolver 要求
owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID
混用。CADFS lowering 现对独立 `new_body` blind prism 的 CAP_FACE,以及满足全部条件的
`LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind
extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把
CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨
boolean 删除成员后的 Compound member-index 改变继续传递 owner/output role;几何回退仍按
member index 隔离,绝不以相似性跨成员继承。该路径不覆盖 drafted 内环、fused/multi-region/
multi-extent/loft/sweep/pattern 或一般 CAP_FACE,故 `CAP_FACE`/`SWEPT_FACE` lowering 仍为
部分完成。`00016195` 的 F1 -> fillet -> F3 和 `00835610` 的 drafted F3 -> F4 subtract ->
F5 都是正例;后者为 `converted_complete` / `rebuilt_approximate`,9 个 feature、3 个实体,
RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差
`0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行
前缀,不伪造可绑定 selector。
3. 建立统一 selector resolver:owner body、source feature、output role、几何签名、
snapshot 和唯一性证明。找不到或多解必须稳定诊断。
4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、
profile query、显式 construction 和退化检测。
非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且
CDSL 显式携带严格递增的参数域时,lowering 才以真实的 two-point B-spline 输出;它
绝不退化为直线。schema、semantic validation、sketch solver 与 adapter 共同拒绝缺失
导数/参数、周期两点和重合端点。solver 以参数域上的 cubic Hermite 采样检查闭环面积,
adapter 仍把端点切线与参数交给 OCP 插值,而非近似多段直线。历史快照中 453 个此类
entity 都带两端导数;当前 10 条历史反例矩阵的 9 个可生成候选保留了 18 条该曲线。
该增量只完成此一 profile contract,不覆盖由 `INTERSECT`、`CAP/SWEPT` 等拓扑查询
派生的 profile,也不把后续 dress-up 或内核失败误记为 spline 成功。
### P1:派生 profile 与拉伸终止
1. 用 P0 topology registry 通用重放 `CAP_FACE`、`CAP_EDGE`、`SWEPT_FACE`、
`SWEPT_EDGE`、`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`,将其物化为可追溯的
profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、
法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output
role 直接消费物理 B-rep 面,未试图物化或重建 profile。
`OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个
`new_body` blind additive prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source
profile 的相邻边 offset 交点、shell thickness、实际 start/end cap frame 构造四边内壁。
它不复用整张 source sketch,也不从 STEP 反推 trim;draft、cut/fused/multi-body source、
多 removal cap、曲线/凹/多 contour profile、多个 source edge 和非正交 span 都保留
`extrude_profile_topology:offset_face` 诊断。`00789939` 的 F15 是严格通过的完整 history
正例;`00588094` 的非直接 provenance 则保持 partial conversion 和五 feature executable
prefix,后续 F15 的 output-role binding failure 仍单独报告。`output/offset-face-linear-wall-20260909-r3`
记录前者 strict/RP 通过;当前 26 条含 `OFFSET_FACE` history 的受控 shard
`output/offset-face-profile-shard-20260909-r1` 为 1 strict、17 rejected、7 rebuild failure、
1 deferred-no-executable-feature,仍有 17 条明确的 `extrude_profile_topology:offset_face`
gap。`output/core-17-offset-face-linear-wall-20260909-r2` 为 4 strict、3 RP、9 rejected、1
comparison timeout,故这仍是派生 profile 的部分完成项而非 general OFFSET replay。
对 CAP_EDGE,现有的受限 outer-profile 规则还会递归展开仅由 `qUnion` 构成的关联嵌套,
再要求恰有一个原始闭合 IMPRINT 外 region 和一个唯一 CAP_EDGE-derived IMPRINT region,
并由两个明确 face-side 判断完整外 region 或外环加孔。该展开只消除 FeatureScript 局部
alias 引入的集合包装,不改变 query provenance,也不放宽 owner、曲线或 side 的唯一性。
`00950564` 的 F3/F5 是这一语义的真实证据:两个 CAP_EDGE profile 都完整 lower,F1--F12
history 也因上述单 body successor rule 完整执行并 RP 通过。它不覆盖一般 CAP_EDGE、CAP
edge 物理重放、COPY/boolean/pattern 后继或未证明的 region 选择。双向 blind extrusion
同样沿用单向 CAP 顺序:`isStart:true` 是主方向的反侧端盖,`isStart:false` 是主方向端盖;
因而 second depth 趋近于零时保持同一 contract,不按 primary/reverse 字段名称猜测。
`00247322` 的 F4 以 `z=40.3 mm` 的反侧 cap 附着 F6,F5 后该 F4 owner 仍有唯一
continuation,F7 的 CAP_EDGE hole profile 可执行。后续 F9 选择已被 F5 消耗的 F4 member,
故保持 `body_source_unavailable`,不烘焙 transform 回 F4 草图或替换为 aggregate body。
刷新的 `output/cap-edge-two-sided-frame-matrix-20260909` 三样本矩阵为 1 个
`rebuilt_approximate`(`00950564`)、1 个 `rebuilt_rejected`(`00803284`)和 1 个
`runtime_ineligible`(`00247322`,但 F1--F7 prefix 可执行)。共享 lowering 的固定核心
17 工件 `output/core-17-two-sided-cap-frame-final-20260909` 分类为 3 strict、3 RP、9 rejected、
1 rebuild failure、1 comparison timeout,与此前共享基线一致。受控全量 24 条 shard
`output/full-shard-two-sided-cap-frame-20260909` 记录 1 strict、2 RP、10 rejected、6 rebuild
failure、3 runtime-ineligible、1 timeout 和 1 无可执行 feature;其 conversion diagnostics
仍归因为已有的 `cPlane`、CAP_EDGE、fillet/chamfer、hole、revolve 与 shell selector 缺口,
没有将它们重分类为双向 CAP frame 成功或失败。
`INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始
analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment side;solver
仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际
B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source
alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用
整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared
contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整
FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件
`output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件:
`00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false`,`00071859` 和
`00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。
当前 `output/drafted-cap-face-20260909` 已证明 `00835610` 在 drafted CAP_FACE continuation
与该 IMPRINT 后续均执行时 RP 通过;这不改变 `planar_imprint` 的其它失败分类,也不构成
该能力的完整覆盖。共享核心 17 回归
`output/core-17-planar-imprint-20260909` 分类保持 3 strict、2 RP、10 rejected、1
rebuild failure、1 comparison timeout。
对同一逻辑 circle source,solver 现在保留一条带 workplane frame 的 OCC circle edge,而不将
它预拆为四条 contour arc;fragment 的 directed successor/predecessor 由 splitter image 在
精确交点处的端点和原 curve 同向切向解析,避免周期参数接缝的数值排序歧义。无 fragment
的 source 代表全体 splitter descendants:只有其给定 face side 的每一个 B-rep face 都有界时,
adapter 才保留完整 face 集;任一 support-boundary face、非唯一 side 或 fragment 都拒绝,
不按 image 顺序/长度选择或静默省略。`output/intersect-circle-imprint-matrix-20260909-r2`
记录 `00001313` F1/F3 完整执行但 RP 拒绝(bbox delta `0.023571 mm`)、`00004246`
的 F1/F3 executable prefix 和三个仍有归因的失败。`00040869` 的 line/ellipse
`OD(0)` pairs 以 OCP exact distance 均为 `0.004240908 mm`,没有 splitter image 或
section edge,故 `output/intersect-circle-imprint-source-gap-20260909-r1` 保留 F1 prefix
并报告 source/anchor 无实际交点,绝不以容差补点;核心 17
`output/core-17-intersect-circle-imprint-20260909-r2` 为 4 strict、3 RP、9 rejected、1
comparison timeout。故这仍只是同草图有界 exact-curve arrangement 的 P1 扩展,不能宣称
general INTERSECT replay 已完成。
2. `UP_TO_NEXT` 已 lower 为 CDSL `through_next`,并有一个受限的 current-body
contract:所有 profile sample ray 都在当前 body 命中时,保留 profile 到首个正向
命中面之前的外部材料;只有部分 ray 命中时,以实际命中 face 做有限体层裁剪;没有
命中时明确拒绝,不退化为盲拉伸。`00192744` 的 F5/F10/F14 已由该 contract 完整
执行,但其独立 body 与 source STEP 的融合差异、后续 multi-body transform 和 source
history/STEP 不一致仍单独分类。这是部分完成,不代表复杂 profile、多 body target、
source body lifecycle 或第二方向终止已支持。
3. 在上述受限路径之外,完成 `up_to_surface`、`up_to_next`、`up_to_body`、
`up_to_vertex`、two-sided、through-all、surface/mixed 的有限交集语义和多 body
target 选择。
4. 仅在所有 profile rays、target body 和 offset 均能由通用几何验证时执行;否则保留
前缀并说明哪一个终止条件无法证明。
### P2:实体特征的完整语义
1. `extrude`、`revolve`、`loft`、`sweep`:补齐实体/曲面模式、复杂 profile、导轨、
section correspondence、twist/transition、path/axis selector 和结果 body。现有 direct
initial/new-body simple sweep 与 initial simple loft 现可将 builder-proven cap evidence
作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持
independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的
derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。
2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role
evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或
two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非
construction `line` 原始实体生成平面 `SWEPT_FACE` placeholder,再由 prefix snapshot
唯一绑定。该规则要求 owner、profile、start/end frame、blind extent 和单 body
lifecycle 都可证明;revolve/sweep/loft、圆或其它非线性 source、changed profile、copy/
pattern/boolean/dress-up continuation 和多 target body 均继续诊断,不能回退到当前 body
或相近面。CADFS `oppositeDirection` 已显式 lower 为 `inward:false`,由既有 OCC
`MakeThickSolidByJoin` 的有符号 offset 执行;这只复用已验证的 removal selector 和单一
selected solid contract,不会将 OCC feasibility failure 伪装成 inward shell 成功。仍需任意
稳定 removal face、多 body target、其它 join/offset 参数语义和更广的 kernel failure
诊断回归。作为进一步的窄例外,紧邻 shell 的 `OFFSET_FACE` removal query 可在其
`disambiguationData` 中只有一个 direct `TDD` CAP source,且 source 是前序 direct
`new_body` blind-prism cap 时 lower 为 `shell.offset_face` 加 source-qualified output role。
没有 `TDD`、多个 source、draft/fused/COPY/pattern/boolean source、非邻接 owner 或无
active unique snapshot 都稳定诊断。`shell.parts` 现有一个独立且同样受限的 body-source
lifecycle contract:只有 parts query 可唯一解析为仍存活的 direct `SWEPT_BODY` independent
member 时,lowering 才写入 CDSL `target_feature_id`;capability preflight 和 runtime 都要求
该 ID 的 exact B-rep solid 仍在 active member graph 中,不能以 `session.body`、current
aggregate 或几何近似替代。`00107631` F3 中 F1 可证明地继续为 `f_F2`,因而是正例;
`00542223` F7 在 fuse/pattern 后不再有独立 member;runtime 仅在 pattern 前后都是一个
可执行实体且 source 唯一时保留 direct source 的 sole-body alias,因此 parts-scoped shell
可以验证为当前实体,而不把 aggregate 伪装成一个独立 member。此路径尚未覆盖 multi-part /
multi-body parts query、COPY、boolean 或 pattern instance 的通用 successor;这些情形必须
保留可执行前缀或既有运行路径,不能宣称 explicit target-body semantics。实现参考
SimpleCADAPI(Apache-2.0)`IsSame` member identity 语义,未复用代码;其单 Solid boolean
假设未被采用,因为它与 CADFS 的独立 body 生命周期不兼容。当前验证为:受影响
lowering/integration/selector/runtime suite `218 passed`(3 条既有 workplane warning);
`output/core-17-shell-parts-20260909` 为 3 strict、3 RP、9 rejected、1 rebuild failure、1
comparison timeout。`output/shell-parts-matrix-20260909` 的 21 实样本中,10 个 shell 写入
explicit `target_feature_id`,结果为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。
`00107631` 的 F3 在这项 target contract 下通过 converter/preflight。其普通、非 instance-locked
`CAP_FACE` selector 只有在 active owner candidates 均不满足完整 geometry signature 时,才可在
当前 active member 中选择唯一的 threshold-qualified face;这不能替代 `output_role` relation,
也不适用于 `owner_match_required` COPY provenance。因而 F3 的两张 removal faces 分别严格绑定
为 `f_F2:face:1` 和由 `(f_F1, extrude.start)` 证明的 `f_F2:face:5`。该 feature 随后由 OCC
报告 invalid thick-solid,而不是 selector-not-found;系统保留 F2 executable STEP prefix,不会
修改厚度、重选面或伪造 shell 成功。`00542223` 的完整 history 现可 rebuild,但仍因几何比较
rejected;这不是 RP 通过声明。`output/core-17-fused-sole-body-pattern-provenance-20260909-r2`
的新代码工件为 3 strict、3 RP、10 rejected、1 comparison timeout;五样本
`output/fused-sole-body-pattern-provenance-20260909-r2` 中 `00542223` 与 `00423838` 均为
rebuilt/rejected,`00293508` 保持 RP,`00253824` 仍在无关 `planar_imprint` 路径失败并保留
F3 prefix。这些结果不能作为 complete shell 或 RP 相似通过的证据。
无 geometry 的普通 context selector 同时保留既有的唯一 active same-kind record 回退;它只在
source owner 没有 active record、context 内候选唯一时成立。`owner_match_required` 的
COPY/instance selector 没有此路径,零 owner candidates 仍是 `selector_not_found`。该边界的
原子正反测试与完整 CADFS 测试均通过(11 个 selector test、118 个 CADFS test)。刷新后的
`output/core-17-selector-context-full-20260909` 仍为 3 strict、3 RP、9 rejected、1 rebuild
failure、1 comparison timeout;`output/shell-parts-selector-context-fallback-matrix-20260909` 的
21 条分类仍为 13 rebuilt-rejected、7 rebuild-failed、1 runtime-ineligible。这是 selector
绑定范围修正,不增加 shell 的已完成语义,也不改变 OCC invalid-shape 的保留前缀诊断。
3. `fillet`、`chamfer`、`hole`:完整 selector、tangent propagation、参数变体、
feasibility 检查和 dress-up 后继。当前 fillet/chamfer lowering 对 direct blind/
mid-plane extrusion 的唯一源端点,以及 direct two-section loft 的每 section 一对唯一
源端点,可生成可绑定的 endpoint-bbox `SWEPT_EDGE` selector;前者额外证明为直线,
后者不声称曲线类型,因为内核可将其物化为 B-spline。独立 `NEW` 的 full solid
revolve 也可在 profile 与 axis 都是同一原始草图的直接来源、original-set 唯一指向一
个非轴端点时,按 axis projection 生成 `circle_center_mm` / `radius_mm` 圆边签名;
adapter 只从实际 OCC circle edge 记录该签名。一个 `IMPRINT` profile 仅在 materialized
workplane 和完整 profile 与原草图完全相等时可使用同一来源证明;任何实际 region 选择
都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、
multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一
端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同
孔型。
4. `booleanBodies`、`mirror`、`circularPattern`:显式 source/target bodies、实例输出、
nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular
`NEW` COPY -> rigid transform,以及 direct mirror 的 `NEW` source -> `instance 1`
COPY -> transform 是受限增量;不代表 linear/nested pattern、镜像 aggregate source 或
fused instance 已有完整 output body lifecycle。mirror 的 `mirrorPlane` 另有一个受限
`SWEPT_FACE` contract:唯一 direct full solid `revolve_add` 的原始 source line 与 revolve
axis 可由既有 `_query_plane` 证明为同一物理平面时,lowering 物化显式
`reference_plane` 后交给现有 `pattern_mirror` 执行。它不从 STEP 或 current body 推断
plane;partial revolve、非 revolve、curve/derived face 或任一未解析 frame 都保留原有
mirror-plane diagnostic。`00000385` 的 F5 是正例(XY plane,F5 executed);
`00023074` 保留拒绝边界。五样本
`output/mirror-revolve-plane-matrix-20260909` 为 2 RP、3 rejected,且只移除了
`00000385` F5 的 plane defer,F2 chamfer 与 F7 fillet 仍如实诊断。`booleanBodies`
现在也有一个独立、受限的 COPY body contract:direct `SWEPT_BODY` 和已存活的
mirror/circular `COPY(SWEPT_BODY)` 分别 lower 为 feature ID 和
`{pattern_feature_id, source_feature_id, instance_index}`,runtime/body graph 仅从该
tuple 解析对应独立 body member,绝不以 producer aggregate 或 current body 代替。
direct `SWEPT_BODY` 若唯一解析到上述 fused sole-body successor,也会以 successor 而非
creator 作为 circular source;`00253824` 的 F1 -> F3 cut -> F4 add -> F5 circular 的
source 因而是 `f_F4`,其 F6 targetless UNION 保留 `f_F4` 与 F5/F4/instance-1,2 的
明确 ownership。该完整样本目前在无关的 F4 `planar_imprint` runtime selection 失败时
保留 F3 executable STEP,不能作为 pattern/boolean RP 通过证据。合成 fused-body
circular COPY -> boolean runtime 回归完整执行。多 body、boolean 后继、nested/linear
pattern、被排除 instance 和 CAP/face topology ownership 仍未覆盖。
targetless FeatureScript `UNION` 只在 `keepTools:false` 且至少有两个 explicit members
时按 query 顺序确定一个 target;`00293508` 的 direct F1/F3 UNION 以 F1 target、F3 tool
完整执行并 RP 通过。targetless subtract/intersect、keep-tools、无效或已经吸收的 instance、
以及 multi-source transform COPY 仍保持拒绝。`00000385` 的 F6 因而以 `f_F1` 为 target、
F5/F1/instance-1 为 tool 完整 lower 并执行,F2/F7 保持其原有 diagnostics。四样本
`output/boolean-pattern-copy-provenance-matrix-20260909` 为 3 rejected、
1 runtime-ineligible;其中其它 sweep、keep-tools/subtract、shell/hole 和 selector 缺口均
未被重分类。新的 `output/core-17-boolean-pattern-copy-provenance-20260909` 仍为 3 strict、
3 RP、9 rejected、1 rebuild failure、1 comparison timeout。direct circular ADD 现将每个
transformed COPY 的面、边、顶点沿 exact OCC transform/fuse history 传入 final active snapshot;
只有一个 source 和一个 final result 的 relation 才传递 `f_pattern.cN.f_source` owner,
split/merge/no-history 仍不声明 owner。`00542223` 的 F7 removal faces 和 `00423838` 的
`COPY(CAP_FACE)` 因而都在 active snapshot 中绑定为真实 instance owner;后二者的最终 STEP
仍分别 rejected,不能将 selector 可执行性描述为几何相似通过。direct-new wedge 的原子回归同时
验证三个 copy owner 都出现在最终 fused body。nested/linear pattern、boolean 后继、被排除
instance 和没有 exact history 的 CAP/face ownership 仍未覆盖。
CADFS 的静态数值表达式还允许无歧义的 `round(N)`,但仅当 `N` 已是有限整数,因而该
表达式可被证明为恒等;`00003011` 的 `round(8)` pattern 由此完整 lower。非整数
`round`、变量和其它函数不会在 converter 中猜测 FeatureScript 的数值语义。这只是
lowering 前置条件,不能作为 COPY owner、pattern instance lifecycle 或 RP 相似通过的
证据。
5. `cPlane`:所有历史出现的 plane constructor、曲线/曲面 attachment、方向手性和
退化输入。`CPlaneType.OFFSET` 必须将 CADFS `oppositeDirection` 编译为 source plane
normal 上的负 signed offset,而不是忽略该 flag 或翻转 plane frame;后者会镜像同一草图的
local coordinates。该 shared lowering contract 已覆盖 272 条实际带该 flag 的 history。
`00777619` 将 Right plane 的 `76.2 mm` offset 修正为 `x=-76.2 mm`;因其 profile 只有部分
射线到达 selected `SWEPT_BODY`,`up_to_body` 仍以非均匀 target 诊断保留 F2 prefix,不能
被这一 plane 修复伪装成成功。Right/Front/Top 的三个真实 matrix 例与 24 条分层 shard
`output/cplane-offset-opposite-shard-20260909` 分别验证 frame 和独立分类:3 strict、1 RP、
13 rejected、7 rebuild failure。共享 core-17
`output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1
rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖
曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。
### P3:尚未支持的 FeatureScript 操作
按全量诊断计数建立 operation registry,逐项为 `draft`、`thicken`、`split`、
`moveFace`、`deleteFace`、`replaceFace`、完整 `transform`、`derive`、`import`、
`bend_add` 建立 schema、lowering、runtime、adapter 和回归。外部资产或生成器缺失时,
必须将 capability 标为不可执行并提供部署诊断,不能在 import 时影响无关模型。
### P4:比较、来源和性能
1. 保留 strict 与 RP 两层几何比较,并为被精确量快速拒绝的边缘模型提供可控的
离线 surface 诊断预算;comparison timeout 是基础设施问题,不能混入几何失败。
2. 输出 source/rebuild 多视图、局部差异、body 体积/面积和最近 feature 的关联证据。
截图只用于人工复核,不能替代多视角 B-rep 比较。
3. 识别 source STEP 非闭合、FeatureScript literal 与 export 精度不一致、缺失资产等
source exception。它们必须保留原始导入/检查证据,不能修饰 CDSL 以伪造通过。
4. 使用 capability shard、缓存中间工件和可恢复 worker 隔离完成全量回归;任何 failure
都必须保留最后一个重新绑定 selector 并以 strict 模式实际执行成功的前缀 STEP、GLB
和诊断。不得把未绑定候选或当前 body fallback 伪装成可执行工件。
## 每项能力的完成定义
一项能力仅在满足以下条件时可标记完成:
1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。
2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector
和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为
部分完成。
3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。
4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和
生命周期组合。
5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余
exception 均更新到本地台账。
6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为
通过比较而降低 RP 阈值的行为。
## 执行节奏
每次改动依次执行:受影响原子测试 -> 受影响真实样本 -> 核心 17 -> 扩展能力矩阵 ->
对应全量 shard。完成一个 capability family 后,更新
`ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`,记录新增的通用 contract、剩余边界、
样本列表、比较证据和下一优先级;该本地台账不提交 Git。
全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力”
和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须
保留输出,不能因未满足最终几何目标而丢弃。
+29 -24
View File
@@ -3,11 +3,12 @@
## 目标
将 CADFS FeatureScript 历史稳定转换为 CDSL,由 engine 重建 STEP,并与
原始 STEP 做严格几何比较。近期交付门槛是 17 个代表性样本全部严格通过;
原始 STEP 做工程几何比较。近期交付门槛是 17 个代表性样本全部达到工程相似;
最终交付门槛是当前 CADFS 全量语料中的每个可比较模型都完成同一条链路。
这里的“重建成功”仅指 `comparison.json` 的 `strict.passed == true`,不是
仅生成 `rebuild.step`,也不是只通过 RP 宽松阈值。
这里的“重建成功”指 `comparison.json` 的 `rp.passed == true`,不是仅生成
`rebuild.step`。`strict.passed` 保留为高精度诊断指标:它用于发现源历史精度、
内核拓扑和局部几何差异,不再作为工程可用模型的唯一验收门槛。
本文件记录目标、阶段门槛和验收口径。实现进度、能力边界和每次代码变更
后的证据记录在本地台账
@@ -22,33 +23,34 @@
FeatureScript 操作、草图实体、已 lower 的 engine atomic 和未支持能力变体。
- 本地能力台账记录该池已有 `17/17 rebuilt` 的可执行 STEP 证据;当前 checked-in
manifest 保留的是历史选择时的 `3` 个 engine baseline / `17` 个 conversion
样本分类。因此,开始严格回归前必须重新生成并核对 manifest,不能将旧分类
样本分类。因此,开始工程回归前必须重新生成并核对 manifest,不能将旧分类
当作当前通过结果。
- 当前全量报告中仍有 `rebuild_failed`、`rebuild_timeout`、`comparison_timeout` 和
`rebuilt_rejected`。这些都是待消除的问题,不可因保留了可执行 STEP 而视为完成。
`comparison.json.decision = rejected`。这些都是待消除的问题;`approximate_pass`
是已重建且工程相似的模型,仍应保留 strict 差异证据供后续能力完善。
## 验收口径
一个样本必须同时具备以下工件和结果,才计入严格重建成功:
一个样本必须同时具备以下工件和结果,才计入工程重建成功:
1. `candidate.cdsl.json` 通过 schema 和语义验证,且没有以跳过后续几何换取成功。
2. `bound.cdsl.json`(若样本需要 selector 绑定)可复现产生,绑定证据可追溯到
feature、owner 和 topology snapshot。
3. `rebuild.step` 由完整的 CDSL feature history 生成;中间失败不能被最终可执行
前缀掩盖。
4. `comparison.json` 的 `decision` 为 `strict_pass`,并同时满足:
`surface_max <= 0.01 mm`、`surface_p99 <= 0.01 mm`、
`bbox_max_delta <= 0.01 mm`、体积和表面积相对误差均不超过 `1e-5`,以及实体数相等。
4. `comparison.json` 的 `decision` 为 `strict_pass` 或 `approximate_pass`,并满足
RP 工程阈值:`surface_max <= 0.02 mm`、`surface_p99 <= 0.02 mm`、
`bbox_max_delta <= 0.02 mm`、体积和表面积相对误差均不超过 `0.005`,以及实体数相等。
5. `status.json`、转换诊断、重建报告和比较报告保存于该样本目录;可执行但不相似
的模型必须保留,而不能在失败时删除。
`approximate_pass` 仅用于定位接近结果,不能通过本计划的 17 样本或全量目标。
缺失原始 STEP、源数据损坏或外部资产不可取得时,必须单独列为 source exception,
附原始证据;不得伪装成 engine 或 converter 已完成。
严格比较失败不自动表示 engine 或 converter 失败。缺失原始 STEP、源数据损坏或
外部资产不可取得时,必须单独列为 source exception,附原始证据;不得伪装成
engine 或 converter 已完成。
## 第一阶段:17 个代表样本
阶段完成条件:下面 17 个样本全部以 `--compare-mode strict` 重新执行并严格通过,
阶段完成条件:下面 17 个样本全部以 `--compare-mode rp` 重新执行并达到工程相似,
同时 manifest 的覆盖标签与当前输出一致。每次修复只针对实际失败根因扩展能力;
一个样本可能由多个能力共同阻塞。
@@ -60,7 +62,7 @@
| `00111611` | extrude、revolve、fillet、chamfer | 复杂草图、回转轴、圆角和倒角的拓扑稳定性。 |
| `00129362` | cPlane、extrude、revolve | 可复用基准面 frame、回转 add/cut。 |
| `00159804` | circularPattern、through-all add | 环形阵列 replay、`line_angle` 和 through-all 加料。 |
| `00192744` | circularPattern、extrude | 已执行 `curve_point` 附着 frame、`up_to_next` 和 pattern copy deletion;仍缺多 body transform 与 source/STEP 一致性证据。 |
| `00192744` | circularPattern、extrude、transform | 已执行 `curve_point` 附着 frame、`up_to_next`、pattern copy deletion,以及 direct circular `COPY(BODY)` 的 multi-body transform;仍缺完整 pattern/body lifecycle 与 source/STEP 一致性证据。 |
| `00212904` | shell、two-sided cut | shell、`three_point`、双向切除终止条件。 |
| `00287955` | loft、cPlane、revolve | 多 profile loft、`cap_edge`、`mid_plane`。 |
| `00423838` | circularPattern、extrude | `up_to_vertex` 与 two-sided cut 的非盲终止。 |
@@ -76,7 +78,7 @@
```bash
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
--tier all --stage pipeline --compare-mode strict --timeout-seconds 60
--tier all --stage pipeline --compare-mode rp --timeout-seconds 60
```
命令结束后应检查 17 个 `comparison.json`,而不是只检查 CLI 的进程退出码。
@@ -84,7 +86,7 @@ PYTHONPATH=backend:. python -m cadfs_to_cdsl regression \
## 能力完成目标
能力的完成定义为同一能力的 CADFS lowering、CDSL contract、runtime/adapter、
selector 或 body 语义(需要时)、严格比较回归均具备。仅实现其中一层时只能记为
selector 或 body 语义(需要时)、工程比较回归均具备。仅实现其中一层时只能记为
“部分完成”。优先级依据全量影响数和 17 样本依赖关系确定。
### P0:使特征历史成为完整实体
@@ -139,7 +141,7 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
## Source Exception Evidence
严格重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
工程重建失败必须先按下表归类。只有“已验证 source exception”能够从全量
成功分母移除;候选项仍是未完成样本,必须保留 source、CDSL、STEP 和比较工件。
| 分类 | 样本 | 当前证据 | 处理要求 |
@@ -148,13 +150,15 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
| engine / converter 缺陷(受限路径已修复) | `00710855` | F5 的 CADFS IMPRINT cut 已由错误圆盘改为 `r=19..25.5 mm` 环。对于 F10,runtime 仅在显式同轴 surface shell 在 selected plane 提供内边界证据时,才以源 `width=10 mm` 构造受限圆锥倒角;完整 history 已可执行。 | 将该受限模式扩展为一般 surface/solid split、topology delta 和后继 selector;不得缩小源倒角宽度。 |
| source / STEP 精度候选 | `00710855` | 完整 history 的 surface 最大差为 `0.0035 mm`,面积、bbox 与实体数满足 strict;但 source F8 E14 是 `r=22 mm`,gold STEP 对应圆柱是 `r=21.9965 mm`,导致体积相对误差 `7.998e-5` 超过 strict。 | 保存 source literal 与 STEP 测量证据;未获得 source export 版本证明前不得回填 gold 半径,也不得从验收分母移除。 |
| source / STEP 差异候选 | `00035682` | FeatureScript 圆柱/孔径 literal 的精度低于原 STEP 的测量值;现有拓扑类型和数量相同,但严格体积/面积不一致。 | 保存原始精度证据;复核 source 导出版本后才能定为 exception。 |
| source / STEP 差异候选 | `00192744` | F3/F5 的 frame 与 `UP_TO_NEXT` 已修复,F7/F12 的 `COPY(BODY)` 删除也已 replay,完整 history 可执行且 bbox 对齐;但 source F15 将 F1 移动约 `-10000 mm`,gold STEP 仍位于约 `y=[-500,500]`。按 source 缺省 `NEW` 保留 8 个实体,gold 只有 1 个,体积相对误差仍为 `1.001e-3`。 | 保持严格失败并完成通用 multi-body transform/body lifecycle;不以 gold STEP 反推、伪造 transform 参数或把 source `NEW` 改成 `ADD`。 |
| source / STEP 差异候选 | `00192744` | F3/F5 frame、`UP_TO_NEXT`、F7/F12 `COPY(BODY)` delete 及 F15 的 direct circular COPY multi-body transform 均已完整执行,零 runtime diagnostic。F15 将 F1/F5/F6 COPY 移动约 `-10000 mm` 后,未被选择的 source `NEW` members 留在原处;当前 rebuild 有 22 个实体、gold STEP 只有 1 个,面积误差约 `3.3%`,RP 失败。 | 保留 executable STEP 和 comparison;继续完成 fused/add、nested、linear/mirror COPY 与 boolean/dress-up 后继的完整 body lifecycle。不得从 gold STEP 回推 union、transform 参数或把 source `NEW` 改为 `ADD`。 |
| source / STEP 差异候选 | `00212904` | 原 STEP 出现 source FeatureScript history 未表达的正交 cut strip。 | 保留对比工件;除非 source 能提供缺失 feature,不得在 converter 伪造几何。 |
| source / STEP 精度候选 | `00287955` | 完整 history 已执行并达到 RP。source STEP 的 F1 顶面为 `z=8.128 mm`,FeatureScript 只有 `8.13 mm`;F3、F5/F6 和 F14 的后续面偏差累计到 `0.002-0.008 mm`,最终体积相对误差为 `1.793e-4`。 | 保留 CDSL 与比较工件;不得根据 source STEP 回填未在 FeatureScript 中出现的精确尺寸。 |
| source / STEP 拓扑候选 | `00542223` | 开放 B-spline F2 已作为 reference 草图完整 lower,完整 history 无诊断并重建为 `1` 个实体、`49775.5119 mm3`。source STEP 虽声明 `MANIFOLD_SOLID_BREP` / `CLOSED_SHELL`,OCC 却导入为 `TopAbs_SHELL`、`0 solids`,并报告 `BRepCheck_NotClosed`。 | 保留严格失败;需要可信 source STEP 或中间 B-rep 后,才能验证完整 shell/mixed-body 语义。 |
| source / STEP 精度候选 | `00835610` | 完整 history 可执行且 RP 通过,严格最大表面差约 `0.014 mm`;FeatureScript 值与 STEP 存在约 `0.014 mm` 的 literal 差异。 | 不能从 gold STEP 回填尺寸;继续标为严格失败,等待 source 版本证据。 |
## 全量完成门槛
在 17 样本严格通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
在 17 样本工程通过后,按 capability family 对全量语料滚动运行,并按失败原因选择
新的最小代表样本。最终报告必须以扫描快照中的全部样本为分母,同时满足:
1. 每个有效源样本生成语义有效、可执行的 CDSL;`parse_failed`、
@@ -162,8 +166,8 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
feature 而导致的 `converted_partial` 均为零。
2. 每个拥有原始 STEP 的样本生成完整 `rebuild.step`;`rebuild_failed` 和
`rebuild_timeout` 均为零。
3. 每个可比较样本严格通过比较;`comparison_timeout`、`rebuilt_rejected` 和仅
`rebuilt_approximate` 均不计入完成。
3. 每个可比较样本以 RP 工程阈值通过比较;`comparison_timeout`、
`comparison.json.decision = rejected` 和仅有可执行 STEP 的样本均不计入完成。
4. 任何合法 source exception 都有单独清单、原始证据和可复现原因;它不进入成功
分母,也不得吞并为“未分类失败”。
5. 生成全量 capability-gap、比较汇总和逐样本证据,确保任一退化都可定位到
@@ -181,15 +185,16 @@ SimpleCADAPI 强制将 boolean 结果收敛为单个 `Solid`,不满足 CADFS
问题。证据不足时不得猜测分类。
3. 只在责任层修复:转换问题修改 CADFS lowering;执行问题扩展 engine;两者都涉及
时分开提交证据和测试。保留最新可执行 STEP/GLB 和失败诊断。
4. 每项代码改动至少重跑受影响样本、17 样本严格回归和相应单元测试;能力状态与
4. 每项代码改动至少重跑受影响样本、17 样本工程回归和相应单元测试;能力状态与
证据同步到本地能力台账。全量统计变化后重新生成 `regression/manifest.json`。
5. 不以关闭诊断、跳过 feature、降低 strict 阈值或用未说明的默认尺寸换取通过。
5. 不以关闭诊断、跳过 feature、用未说明的默认尺寸或放宽 RP 阈值换取通过;严格
指标的变化必须作为诊断证据单独说明。
## 阶段交付物
| 阶段 | 必须交付 | 通过条件 |
| --- | --- | --- |
| R0:证据稳定 | 刷新的 regression manifest、17 份完整工件 | 每个样本可重复运行并可定位当前失败。 |
| R1:代表样本 | 17 个严格比较报告、能力台账更新 | `17/17 strict_pass`。 |
| R1:代表样本 | 17 个工程比较报告、能力台账更新 | `17/17 rp.passed`。 |
| R2:能力扩展 | 按能力 family 的 converter/engine/selector 实现及回归 | 对应缺口不再造成该 family 的失败。 |
| R3:全量验证 | 全量 CDSL、STEP、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
+9 -1
View File
@@ -24,7 +24,15 @@ def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]:
from onshape_to_cdsl.compare import strict_compare
# ``onshape_to_cdsl`` is a sibling src-layout package. CADFS commands
# intentionally require only ``PYTHONPATH=backend:.`` so they must work
# from a source checkout as well as an installed package.
try:
from onshape_to_cdsl.compare import strict_compare
except ModuleNotFoundError as error:
if error.name != "onshape_to_cdsl.compare":
raise
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
raw = strict_compare(
gold_step,
rebuilt_step,
+2205 -171
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -117,6 +117,12 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
if outcome == "completed":
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
bound_cdsl = result.pop("bound_cdsl", None)
prefix = result.get("last_executable_prefix")
if isinstance(prefix, dict):
prefix_bound_cdsl = prefix.pop("bound_cdsl", None)
if bound_cdsl is None and prefix_bound_cdsl is not None:
bound_cdsl = prefix_bound_cdsl
prefix["bound_feature_count"] = prefix.get("feature_count")
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
else:
step_path.unlink(missing_ok=True)
@@ -144,6 +150,10 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result"
status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status
else: comparison = read_json(comparison_path)
# A forced retry can recover from a transient worker timeout. The final
# status must describe the successful comparison rather than retaining a
# stale infrastructure failure beside an accepted result.
status.pop("comparison_error", None)
status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"]
status["status"] = "rebuilt_strict" if comparison["strict"]["passed"] else "rebuilt_approximate" if accepted else "rebuilt_rejected"
+49 -1
View File
@@ -1,9 +1,52 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import re
from typing import Any
def _last_executable_prefix(
cdsl: dict[str, Any],
failed_feature_id: str | None,
output: Path,
) -> dict[str, Any] | None:
"""Bind and export the longest verified prefix before a failed feature."""
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import rebuild_cdsl
features = list(cdsl.get("features") or [])
failed_index = next(
(index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id),
len(features),
)
for feature_count in range(failed_index, 0, -1):
prefix = deepcopy(cdsl)
prefix["features"] = features[:feature_count]
try:
bound_prefix, _binding = bind_candidate_selectors(prefix)
result = rebuild_cdsl(bound_prefix, output, strict=True)
except Exception:
continue
return {
"failed_feature_id": failed_feature_id,
"feature_count": feature_count,
"last_feature_id": str(features[feature_count - 1].get("id") or ""),
"bound_cdsl": bound_prefix,
"result": result,
}
return None
def _failed_feature_id(error: Exception) -> str | None:
diagnostic = getattr(error, "diagnostic", None)
feature_id = getattr(diagnostic, "feature_id", None)
if isinstance(feature_id, str) and feature_id:
return feature_id
match = re.match(r"([^:\s]+): ", str(error))
return match.group(1) if match else None
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from .selector_binding import bind_candidate_selectors
@@ -11,6 +54,7 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
bound: dict[str, Any] | None = None
try:
bound, binding = bind_candidate_selectors(cdsl)
result = rebuild_cdsl(bound, output, strict=True)
@@ -18,4 +62,8 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output)
result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
if bound is not None: result["bound_cdsl"] = bound
if prefix is not None: result["last_executable_prefix"] = prefix
return result
+73 -4
View File
@@ -10,7 +10,7 @@ from typing import Any
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
scores: list[float] = []
reversed_plane_normal = False
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
for key in ("center_mm", "circle_center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"):
if key in expected:
# plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的
# plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。
@@ -101,6 +101,7 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]])
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
selector = {"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}}
if placeholder.get("binding_feature_id") is not None: selector["binding_feature_id"] = placeholder["binding_feature_id"]
if placeholder.get("owner_match_required"): selector["owner_match_required"] = True
bound.append(selector)
if placeholder.get("match_mode") == "all":
selector = dict(placeholder); selector["matched_selectors"] = bound
@@ -150,23 +151,76 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
resolved = []
for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
output_role = str(placeholder.get("output_role") or "").strip()
if output_role:
# Builder output roles are not geometry placeholders. They
# remain in the bound CDSL so runtime can resolve the
# current active B-rep face through exact kernel history.
# Replacing one with stable_id/geometry would mix evidence
# and make a stale snapshot appear durable.
owner = placeholder.get("owner_feature_id")
role_source = placeholder.get("output_role_source")
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
candidates = [
record for record in records
if record.get("kind") == placeholder.get("kind")
and owner in (record.get("owner_feature_ids") or [record.get("feature_id")])
and output_role in (record.get("output_roles") or [])
and (
role_source is None
or any(
item.get("output_role") == output_role
and item.get("owner_feature_id") == source_owner
and item.get("source_output_role") == source_role
for item in record.get("output_role_sources") or []
)
)
]
if len(candidates) != 1:
status = "not_found" if not candidates else "ambiguous"
raise ValueError(
f"{feature['id']}: selector_output_role_{status} after prefix rebuild"
)
resolved.append(candidates[0])
continue
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
owner = placeholder.get("owner_feature_id")
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
pool = owner_matches or same_kind
# Pattern instance provenance is stronger than an ordinary
# feature owner: an unresolved instance cannot fall back to
# an aggregate face with matching geometry. Ordinary source
# selectors retain their established geometry-binding path
# across topology-changing dress-up operations.
pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind)
if not geometry:
# Context selectors (notably a generated mirror plane)
# may have no geometric snapshot. Their owner-qualified
# singleton identity is sufficient and must not be scored
# as a zero-information geometric match.
if len(pool) != 1:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
status = "not_found" if not pool else "ambiguous"
raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild")
candidates = [pool[0]]
else:
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
# A source owner is preferred for ordinary geometry
# selectors, but can be retained by an unrelated exact
# continuation after a dress-up. If none of that
# owner's active records satisfies the full geometry
# signature, bind against the current active body and
# still require a unique threshold-qualified match.
# Instance-qualified selectors never take this path.
if not scored and not placeholder.get("owner_match_required"):
scored = [
(score, record)
for record in same_kind
if (score := _score(geometry, record.get("geometry") or {})) is not None
and score >= 0.8
]
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
if scored:
if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
@@ -177,7 +231,22 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
placeholder.clear(); placeholder.update(selector)
resolved.extend(bound_selectors)
feature_selectors = feature.get("selectors") or []
unique = {selector["stable_id"]: selector for selector in feature_selectors}; feature["selectors"] = list(unique.values())
def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
unique = {selector_key(selector): selector for selector in feature_selectors}
feature["selectors"] = list(unique.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
+71
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from copy import deepcopy
import tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.compare import compare_steps
@@ -130,6 +131,32 @@ class IntegrationTests(unittest.TestCase):
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
def test_outward_cap_shells_lower_and_rebuild_prefixes(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = (("00090436", 2.5), ("00107631", 3.8))
for sample_id, thickness in cases:
with self.subTest(sample_id=sample_id):
feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), sample_id), {})
shell_index, shell = next(
(index, item)
for index, item in enumerate(result.cdsl["features"])
if item["id"] == "f_F2"
)
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual(shell["params"], {"thickness_mm": thickness, "inward": False})
self.assertNotIn("shell_outward", {item.get("capability") for item in result.diagnostics})
prefix = deepcopy(result.cdsl)
prefix["features"] = prefix["features"][:shell_index + 1]
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 1)
def test_sweep_00542223_preserves_its_open_bspline_path(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
@@ -137,13 +164,57 @@ class IntegrationTests(unittest.TestCase):
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(result.status, "converted_complete")
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(sweep["sketch_id"], "sketch_F4")
self.assertEqual(segment["points"], [[-40.0, -50.0], [-20.0, -14.96], [0.0, 5.0]])
self.assertEqual(segment["start_tangent"], [27.94, 92.49])
self.assertEqual(segment["end_tangent"], [52.88, 49.52])
self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
def test_circular_pattern_00542223_preserves_all_sweep_arms(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
candidate = dict(result.cdsl)
candidate["features"] = result.cdsl["features"][:4]
from engine.cdsl_engine.runtime import rebuild_cdsl
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(candidate, Path(tmp) / "pattern.step")
self.assertEqual(rebuilt["solid_count"], 1)
bbox = rebuilt["bbox_mm"]
self.assertAlmostEqual(bbox["min"][0], -52.14101625137758)
self.assertAlmostEqual(bbox["min"][1], -57.500000100000065)
self.assertAlmostEqual(bbox["max"][0], 52.14101625137762)
self.assertAlmostEqual(bbox["max"][1], 37.5000001000001)
def test_fused_body_circular_copy_faces_bind_and_shell_00542223(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00542223"), {}).cdsl
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]])
shell_selectors = [
item for item in result["selector_resolution"]
if item["feature_id"] == "f_F7"
]
self.assertEqual(
{item["selector"]["owner_feature_id"] for item in shell_selectors},
{"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"},
)
self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors))
def test_face_chamfer_ignores_periodic_seams_00111611(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
File diff suppressed because it is too large Load Diff
+135 -20
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.rebuild import rebuild_candidate
from cadfs_to_cdsl.selector_binding import _score, bind_candidate_selectors
@@ -33,6 +37,66 @@ class SelectorBindingTests(unittest.TestCase):
def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None:
self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0)
def test_geometry_free_context_selector_uses_unique_active_record_when_owner_is_stale(self) -> None:
cdsl = {
"features": [
{"id": "f_source", "atomic_id": "reference_plane"},
{
"id": "f_mirror",
"atomic_id": "pattern_mirror",
"params": {},
"selectors": [{"kind": "plane", "owner_feature_id": "f_source"}],
},
],
}
report = {
"feature_results": [],
"topology_records": [{
"kind": "plane",
"record_id": "context:plane:1",
"owner_feature_ids": ["f_live_plane"],
"geometry": {},
}],
}
with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report):
bound, _ = bind_candidate_selectors(cdsl)
selector = bound["features"][1]["selectors"][0]
self.assertEqual(selector["owner_feature_id"], "f_live_plane")
self.assertEqual(selector["stable_id"], "context:plane:1")
self.assertEqual(bound["features"][1]["params"]["mirror_plane"], selector)
def test_geometry_free_instance_selector_does_not_fall_back_to_active_record(self) -> None:
cdsl = {
"features": [
{"id": "f_source", "atomic_id": "reference_plane"},
{
"id": "f_mirror",
"atomic_id": "pattern_mirror",
"params": {},
"selectors": [{
"kind": "plane",
"owner_feature_id": "f_source",
"owner_match_required": True,
}],
},
],
}
report = {
"feature_results": [],
"topology_records": [{
"kind": "plane",
"record_id": "context:plane:1",
"owner_feature_ids": ["f_live_plane"],
"geometry": {},
}],
}
with patch("engine.cdsl_engine.runtime.rebuild_cdsl", return_value=report):
with self.assertRaisesRegex(ValueError, "f_mirror: selector_not_found after prefix rebuild"):
bind_candidate_selectors(cdsl)
def test_swept_face_area_lower_bound_rejects_coplanar_fragment(self) -> None:
expected = {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "minimum_area_mm2": 285.0}
self.assertIsNone(_score(expected, {"normal": [0.0, 1.0, 0.0], "plane_offset_mm": 54.69, "area_mm2": 0.64}))
@@ -50,28 +114,79 @@ class SelectorBindingTests(unittest.TestCase):
self.assertEqual(score, 1.0)
def test_intersection_vertex_binds_pattern_and_current_body_prefixes(self) -> None:
def test_cap_face_output_role_is_validated_without_snapshot_rebinding(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0001/00016195.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {})
bound, evidence = bind_candidate_selectors(candidate.cdsl)
selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0]
self.assertEqual(selector, {
"kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end",
"source": "runtime_snapshot", "confidence": 1.0,
})
self.assertNotIn("stable_id", selector)
self.assertNotIn("snapshot_id", selector)
self.assertNotIn("geometry", selector)
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(len(binding["resolved"]), 1)
self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"])
def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0010/00107631.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
self.assertEqual(candidate.status, "converted_complete")
# F2 retains F1's end-cap provenance, but F3 also selects the distinct
# start-cap plane. Its direct owner has no geometry-qualified active
# candidate, so binding must consider the unique active F2 face without
# weakening the exact qualified OFFSET_FACE role.
prefix = deepcopy(candidate.cdsl)
prefix["features"] = prefix["features"][:3]
bound, evidence = bind_candidate_selectors(prefix)
first, offset = bound["features"][-1]["selectors"]
self.assertEqual(first["owner_feature_id"], "f_F2")
self.assertEqual(first["stable_id"], "body:f_F2:face:1")
self.assertEqual(first["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(first["source"], "runtime_snapshot")
self.assertEqual(first["confidence"], 1.0)
self.assertEqual(offset, {
"kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face",
"output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"},
"source": "runtime_snapshot", "confidence": 1.0,
})
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5")
# Binding must not hide the OCC feasibility boundary. The requested
# second shell currently produces an invalid shape, so preserve the
# F2 prefix instead of changing thickness or removal faces.
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape")
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2")
def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
bound, evidence = bind_candidate_selectors(candidate.cdsl)
reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2]))
f7 = next(item for item in bound["features"] if item["id"] == "f_F7")
reference = f7["params"]["end_condition"]["reference"]
components = reference["intersection_of"]
self.assertEqual(len(components), 3)
self.assertEqual([item["binding_feature_id"] for item in components], ["f_F4", "f_F4", "f_F6"])
# 绑定发生在 F4 完整 pattern 前缀;owner 保留 instance 4 的语义来源,
# snapshot 则指向该前缀的 active Compound B-rep。
self.assertEqual([item["owner_feature_id"] for item in components[:2]], ["f_F4.c4.f_F1", "f_F4.c4.f_F1"])
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F4:") for item in components[:2]))
self.assertEqual(components[2]["match_mode"], "all")
# 同一圆柱面可能被前缀 boolean 切成不同数量的 B-rep face;关键是
# match_mode=all 保留每个可匹配片段,而不是把它收缩为任意一个面。
matched = components[2]["matched_selectors"]
self.assertTrue(matched)
self.assertTrue(all(item["owner_feature_id"] == "f_F6" for item in matched))
self.assertTrue(all(item["snapshot_id"].startswith("body:f_F6:") for item in matched))
f7_evidence = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertEqual(len(f7_evidence["resolved"]), len(matched) + 2)
bound, evidence = bind_candidate_selectors(candidate.cdsl)
reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
copy_components = reference["intersection_of"][:2]
self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components))
self.assertTrue(all(item.get("snapshot_id") for item in copy_components))
binding = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertGreaterEqual(len(binding["resolved"]), 2)
+5 -5
View File
@@ -1,20 +1,20 @@
{
"schema": "cdsl.corpus-manifest.v1",
"corpus_version": "2026-09-07",
"corpus_version": "2026-08-31",
"document_count": 2881,
"document_stems_sha256": "4c8660ed96f3b5427ad26adadd9b82df447951913920542501ef0fc479c02773",
"document_stems_sha256": "ba5b905191817cd0f7b9ad07d4002ad27730e200a395f71f9c4e31ebb1867cd6",
"phase_pools": {
"p3": {
"selector_count": 771,
"selectors_sha256": "2c0f76833cd08592b71ef3a5b5b1548b649ac485e2b2388358426f815c21a047"
"selectors_sha256": "72093701357cd1858f2dbcc0f1ab9e6c7baea7db26357154e0586e5e1be6f801"
},
"p4": {
"selector_count": 826,
"selectors_sha256": "c031552f9ae29f03ca58545d90dc8b16e2b5963f0923ff075e3724f066a2bf3d"
"selectors_sha256": "ab9ea6edd8532ce7a0110d0725b5303c63a53617a0af502101b12f657285d371"
},
"p6": {
"selector_count": 893,
"selectors_sha256": "de44a06e55aa21940e9ccb02d0deaba90bb7669d8fb93a62bf976c903a18c42f"
"selectors_sha256": "ac9f91b690bc533b1fdd1772d65ee77b248d108f39a429615d909b78332d9a08"
}
}
}