Compare commits
13 Commits
7a2ce157f1
...
cf9cd0366a
| Author | SHA1 | Date | |
|---|---|---|---|
| cf9cd0366a | |||
| 6fa4501f16 | |||
| 95cae203f4 | |||
| ad88d92ab9 | |||
| beac59fc8b | |||
| 5ffb106f36 | |||
| 871070c440 | |||
| 3fb08423da | |||
| f79c28759d | |||
| a5c3ec1f36 | |||
| 2f89d7930c | |||
| 9ab20db443 | |||
| cf991dd619 |
@@ -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 明确支持。
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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(便携:显式路径,无项目目录假设)
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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、工件和原因。任何仍可执行的模型都必须
|
||||
保留输出,不能因未满足最终几何目标而丢弃。
|
||||
@@ -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、比较结果和汇总报告 | 满足“全量完成门槛”的五项条件。 |
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user