Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc18429bb | |||
| 273b4b5c7b | |||
| 09a25cdc6b | |||
| 48cec45fee | |||
| de21ee8a5b | |||
| c6d3bfddec | |||
| 994d06aaea | |||
| adafac9f08 | |||
| d8fc2a9207 | |||
| 21a5185926 | |||
| 387b99e255 | |||
| cf9cd0366a | |||
| 6fa4501f16 | |||
| 95cae203f4 | |||
| ad88d92ab9 | |||
| beac59fc8b | |||
| 5ffb106f36 | |||
| 871070c440 | |||
| 3fb08423da | |||
| f79c28759d | |||
| a5c3ec1f36 | |||
| 2f89d7930c | |||
| 9ab20db443 | |||
| 7a2ce157f1 | |||
| cf991dd619 | |||
| 235d3bf0c8 | |||
| 673ae62e70 | |||
| c0475c5df5 | |||
| 5bd1011335 | |||
| 6e8b994ee7 | |||
| 738934416e | |||
| f48ba19dfa | |||
| c524f0213c | |||
| 2414904b02 | |||
| 00b50b1f31 | |||
| d518442565 | |||
| e7dc521ece | |||
| d2a33390dd | |||
| 038d38ed98 | |||
| 4ad890b7b4 | |||
| c1b84d1f76 | |||
| 4f068068f9 | |||
| 921ba5eab3 | |||
| 9e27f11bb7 |
@@ -32,8 +32,10 @@ build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Runtime data and generated local artifacts
|
||||
output/
|
||||
backend/data/
|
||||
backend/live-evals/
|
||||
cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md
|
||||
data/cadfs-sample/
|
||||
json_to_cdsl/input/
|
||||
onshape_to_cdsl/input/
|
||||
|
||||
@@ -29,3 +29,104 @@
|
||||
## 改动评审指引
|
||||
|
||||
修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。
|
||||
|
||||
## CADFS 全量能力开发
|
||||
|
||||
CADFS 到 CDSL 到 STEP 的工作是在实现通用绘图引擎和通用 converter,不是为回归样本
|
||||
编写修复脚本。能力实现必须以 FeatureScript 语义、显式 CDSL contract、body
|
||||
生命周期和内核拓扑为边界,并对同类输入普遍成立。
|
||||
|
||||
- 禁止按 `sample_id`、路径、特定 feature ID、特定坐标、尺寸或 gold STEP 测量值分支;
|
||||
禁止从原 STEP 回填 FeatureScript 未提供的参数,或为某模型硬编码 selector、profile、
|
||||
偏移量、布尔策略和默认尺寸。
|
||||
- 不得将不支持或不唯一的语义伪装成盲拉伸、当前 body、默认 union、任意相近拓扑元素
|
||||
或静默跳过。必须保留最佳可执行前缀和 STEP,并给出稳定、可归因的能力诊断。
|
||||
- 优先在 schema、semantic validation、runtime state、body graph 和 adapter 的
|
||||
kernel-level topology delta 中实现能力;lowering 层只能表达源语义,不能承担样本化
|
||||
的几何补丁。受限算法必须有通用、可验证的适用条件和拒绝路径。
|
||||
- 一项能力只有在 lowering、CDSL contract、runtime/adapter、selector/body 语义、
|
||||
单元测试和多个真实语料回归均具备后才能标为完成。操作名称覆盖不等于参数、拓扑来源、
|
||||
几何类型或 body 生命周期覆盖。
|
||||
- 严格比较用于诊断;工程验收遵循 `rp.passed`。不得降低 RP 阈值、关闭诊断、跳过
|
||||
feature 或修改 source 参数来换取通过。source STEP 损坏或历史/STEP 精度不一致必须
|
||||
作为有证据的 source exception 单独报告。
|
||||
- 每次涉及 CADFS lowering、CDSL schema、runtime、adapter、selector 或测试的改动,
|
||||
都必须同步更新 `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;全量能力
|
||||
计划以 `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` 为准。
|
||||
|
||||
## CADFS 修改、文档与回归纪律
|
||||
|
||||
以下规则适用于每一次 CADFS converter、CDSL engine、selector、比较器、回归集或相关
|
||||
测试的修改;它们是仓库规则,不依赖当前对话上下文。
|
||||
|
||||
### 修改前与实现方式
|
||||
|
||||
- 先用完整 FeatureScript history、`candidate.cdsl.json`、`bound.cdsl.json`、
|
||||
`diagnostics.json`、`rebuild.json`、`comparison.json` 和 source/rebuild 工件定位失败层:
|
||||
converter/lowering、schema/semantic validation、selector binding、runtime/adapter、
|
||||
source STEP/FeatureScript 不一致,或 comparison infrastructure。没有证据不得把失败
|
||||
归因于任一层,也不得先改阈值或样本数据。
|
||||
- 修改必须遵循目标模块已有的命名、分层、格式、注释语言和错误处理模式。优先扩展既有
|
||||
contract、helper 和架构边界;没有明确收益时不得进行无关重构或引入平行实现。
|
||||
- 先实现可复用的几何和拓扑语义,再将 CADFS source lower 到该 contract。任何只为单个
|
||||
形状、数值、feature history 或截图成立的逻辑均视为缺陷,不得提交。
|
||||
- 内核无法完成某个输入时,保留有界的失败和诊断;不得用较小的 dress-up 半径、替代孔型、
|
||||
固定 extent、隐式 fuse 或未经来源证明的几何补偿伪造结果。
|
||||
- 当前实现若被证明确实违反 source/contract 语义、无法表达全量已出现的通用输入、
|
||||
受内核 API 的结构性限制,或反复需要样本化补丁,应停止继续叠加补丁并评估替代方案。
|
||||
替换前必须具备可复现失败、根因证据、与成熟开源/官方 API 或最小原型的对照、对
|
||||
CDSL/body/selector 兼容性的影响评估,以及受影响回归的迁移计划。
|
||||
- 不得因单个样本、偶发 OCC 失败、一次性能波动或主观偏好轻易重写成熟路径。只有新方案
|
||||
能以更少特例、更完整的通用语义和可验证的回归证据解决结构性问题时,才替换旧方案;
|
||||
替换过程中保留旧工件和比较基线,分阶段迁移并记录回滚边界。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- 每次实现、修复、扩展或确认某项 CADFS 能力后,必须在同一工作变更中更新
|
||||
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`:记录能力状态、通用语义、
|
||||
已验证边界、未覆盖边界、受影响样本/能力矩阵和测试/比较证据;完成项必须勾选,
|
||||
未完成项不得因单个样本通过而勾选。
|
||||
- 上述 `.local.md` 是本地工作台账,必须保持在 `.gitignore` 中,绝不加入 Git 提交。
|
||||
每次 CADFS 相关代码变更后都要同步,即使最终只得到失败诊断或发现 source exception。
|
||||
- 新增能力、能力范围、验收口径、全量快照或实施优先级变化时,同步更新受版本控制的
|
||||
`cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`。它定义全量能力矩阵和路线;
|
||||
`CADFS_RECONSTRUCTION_TARGET.md` 记录核心回归与当前近期目标,两者不得冲突。
|
||||
- 从全量 output 发现新的操作、参数变体、拓扑来源、body lifecycle 或 comparison failure
|
||||
类型时,必须先登记为待办和能力矩阵条目,再选择多个真实样本回归;不得只加入一个
|
||||
“代表模型”就宣称覆盖完成。
|
||||
- 文档中的计数、样本 ID、状态和通过标准必须来自当前工件。必须区分 strict、RP 工程相似、
|
||||
几何拒绝、可执行前缀、转换/运行时失败、比较超时和 source exception。
|
||||
|
||||
### 回归与可视证据
|
||||
|
||||
- 每次代码修改至少运行受影响的原子/单元测试和真实语料样本;涉及共享 runtime、
|
||||
selector、body lifecycle 或 comparison 时,还必须运行核心 17、相应扩展能力矩阵和
|
||||
可控的全量 shard。报告未运行的范围和原因,不能将缓存旧工件当作新代码的证据。
|
||||
- 对用户要求查看或人工判定的样本,生成并保留 source STEP 与 rebuild STEP 的一致视角
|
||||
对比截图;截图只辅助人工审查,最终分类仍以完整 history、B-rep 比较和诊断为准。
|
||||
- 任何失败都保留最后可执行 STEP、GLB(如可生成)、comparison/diagnostic 工件和前缀
|
||||
信息。不可执行或不相似不允许清理、覆盖或隐藏已有可用工件。
|
||||
- 提交前执行与改动比例相称的测试、`git diff --check`,并确认本地能力台账未被 staged。
|
||||
除非用户明确要求,不得提交、push、覆盖用户未提交修改或变更回归基线。
|
||||
|
||||
### 开源实现与 SimpleCADAPI 参考
|
||||
|
||||
- 在自行设计 shell、sweep、loft、boolean、fillet/chamfer、transform、topology tracking、
|
||||
body graph 或比较基础设施前,必须先检索成熟的开源实现、官方 OCCT/OCP API 和已有
|
||||
项目依赖;优先复用经过测试的算法、内核调用模式或小范围实现,而不是重新发明基础
|
||||
B-rep 算法。外部代码的许可证、版本兼容性、异常语义和维护状态必须先核实。
|
||||
- 本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`。重点阅读其
|
||||
`src/simplecadapi/topology/tracking.py`、`kernel/ocp_booleans.py`、
|
||||
`kernel/ocp_topology.py`、`kernel/ocp_transforms.py` 及对应 tests/docs:其中的
|
||||
OCC builder history、`Modified`/`Generated`/`IsDeleted`、section edges、same-domain
|
||||
cleanup、显式 shape transform、shell/sweep/loft 调用边界是当前 engine 的优先参考。
|
||||
- 借鉴必须经过本项目的 CDSL schema、runtime body graph、selector provenance 和
|
||||
回归测试适配。不得直接替换本项目的 CDSL contract,也不得照搬其“强制单一 Solid”
|
||||
的 union/cut 语义,因为 CADFS 需要保留独立 body、copy、keep tools 和 pattern
|
||||
instance 生命周期。
|
||||
- 每次因开源参考新增或调整能力时,在本地能力台账记录参考来源、采用的通用语义、
|
||||
未采用部分及理由、许可证/依赖影响和本项目回归证据。无法安全采用时,也应记录
|
||||
评估结论,避免后续重复实现或重复调研。
|
||||
- 发现现有方案方向错误或外部方案明显更适合时,可替换而不是继续打补丁;但必须满足
|
||||
“修改前与实现方式”中的结构性根因和对照验证门槛,并在台账记录替换理由、迁移影响、
|
||||
保留的 contract、回归结果和可回滚边界。
|
||||
|
||||
+1
-14
@@ -2,7 +2,7 @@
|
||||
# CDSL_DEFAULT_PROVIDER=deepseek
|
||||
# CDSL_DEFAULT_MODEL=deepseek-v4-flash
|
||||
CDSL_DEFAULT_PROVIDER=openai
|
||||
CDSL_DEFAULT_MODEL=gpt-5.4-mini
|
||||
CDSL_DEFAULT_MODEL=gpt-5.5
|
||||
|
||||
# DeepSeek. Fill in your own API key below.
|
||||
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
@@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
|
||||
CDSL_LLM_TIMEOUT_S=90
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Final autonomous-task publication uses this independent vision reviewer.
|
||||
CDSL_REVIEW_PROVIDER=openai
|
||||
CDSL_REVIEW_MODEL=gpt-5.5
|
||||
|
||||
# Optional OpenAI provider. Comma-separate enabled models; list vision models
|
||||
# separately so image attachments can be routed safely.
|
||||
@@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
|
||||
CDSL_KIMI_API_KEY=
|
||||
CDSL_KIMI_MODELS=moonshot-v1-8k
|
||||
CDSL_KIMI_VISION_MODELS=
|
||||
|
||||
# Autonomous CDSL agent limits. These protect an active modelling head, not
|
||||
# the total feature count of a CAD task.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
|
||||
+20
-37
@@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium
|
||||
```
|
||||
|
||||
Use `low`, `medium`, or `high` according to the latency/cost versus quality
|
||||
tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to
|
||||
authoring, streaming, and visual-review requests. Leave it empty to use the
|
||||
tradeoff. The setting is sent as the provider's `reasoning_effort` field to
|
||||
requirements analysis and Authoring CDSL generation. Leave it empty to use the
|
||||
provider/model default. The selected OpenAI-compatible endpoint must support
|
||||
the requested value.
|
||||
|
||||
## Autonomous CDSL Agent Configuration
|
||||
|
||||
The autonomous agent writes one frozen free-form `requirements.md`, then
|
||||
observes, measures, renders and appends one CDSL feature at a time. Its author
|
||||
uses normal function calls; no provider strict JSON Schema capability or
|
||||
complete modelling DAG is required. Candidate fragments are rebuilt in a
|
||||
staging directory through `cdsl_only` before a checkpoint can be committed.
|
||||
Each task has one bounded workflow:
|
||||
|
||||
Final publication requires a separately configured vision-capable review model
|
||||
and the Python OpenCascade/Pillow technical renderer. The agent may build and
|
||||
inspect intermediate checkpoints without image review; a final run fails
|
||||
closed if its independent review configuration is unavailable.
|
||||
```text
|
||||
request analysis -> complete cad.author.v1 -> server compilation -> runtime build
|
||||
-> at most two complete repairs -> final or best-effort publication
|
||||
```
|
||||
|
||||
The model outputs only local body/feature names and declarative selectors.
|
||||
The server validates strict schemas, allocates Runtime CDSL identities,
|
||||
compiles references, executes dependencies, and preserves the last executable
|
||||
prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow
|
||||
render bundle are generated from the same published revision.
|
||||
|
||||
```dotenv
|
||||
# Must name one configured provider and one model listed in that provider's
|
||||
# CDSL_<PROVIDER>_VISION_MODELS setting. It is intentionally not inferred
|
||||
# from the authoring model.
|
||||
CDSL_REVIEW_PROVIDER=deepseek
|
||||
CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp
|
||||
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
|
||||
|
||||
# Install Python rendering dependencies. The renderer reads the revision STEP
|
||||
# file and creates canonical images without a browser or GPU driver.
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Limits apply to the current checkpoint head, never to total task complexity.
|
||||
CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12
|
||||
CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3
|
||||
CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6
|
||||
CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6
|
||||
CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000
|
||||
CDSL_AGENT_RENDER_CACHE=true
|
||||
```
|
||||
|
||||
The author chooses each coherent 1-6 feature batch. Every rebuilt batch is
|
||||
rendered and independently reviewed before it can become a checkpoint; only
|
||||
an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the
|
||||
`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and
|
||||
reports are available only after the task reaches `COMPLETED`.
|
||||
|
||||
The backend assigns feature and sketch IDs, appends causal dependencies and
|
||||
expands only opaque current-snapshot selector tokens. It does not compile
|
||||
geometry templates or correct workplanes, profiles, sizes, directions or
|
||||
boolean semantics authored by the model. Failed candidates remain auditable
|
||||
but never become revisions.
|
||||
The initial generation plus two repairs are the only model calls allowed after
|
||||
requirements analysis. A repair returns a complete replacement Authoring CDSL
|
||||
and may only alter diagnosed features. Runtime selector ambiguity, missing
|
||||
selectors, and unavailable dependencies produce stable diagnostics rather than
|
||||
topology guesses. Requirement compliance is reported independently as
|
||||
`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is
|
||||
still published after the repair budget is exhausted.
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Authoring CDSL Modeling Guide
|
||||
|
||||
Write one complete `cad.author.v1` document. It is declarative source for a
|
||||
server compiler, not the runtime CDSL and not an execution log. Return only
|
||||
the schema-valid object requested by the tool.
|
||||
|
||||
## Contract Boundary
|
||||
|
||||
Use lower-case local `name` values for bodies and features. They are symbols
|
||||
within this document only. Never emit an `id`, `feature_id`, `sketch_id`,
|
||||
`body_id`, `task_id`, revision, candidate, stable topology identifier,
|
||||
snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
|
||||
inside `params`. The server allocates identities and injects selector values
|
||||
into the destination stated by the operation contract.
|
||||
|
||||
Literal request language such as `max_z`, `min_z`, `host face`, or a face name
|
||||
is design intent, never a CDSL field or output role. Never emit `host_face`,
|
||||
`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in
|
||||
any JSON value, including `selector.source`. A selector is only `{kind,
|
||||
source, match}`, and `source` must use an exact role advertised by the producer
|
||||
operation.
|
||||
|
||||
Treat `requirements`, prior documents, and diagnostics as semantic input, not
|
||||
CDSL templates. Their keys are descriptive labels. Copy geometry, values, and
|
||||
relations only into fields allowed by the active schema and operation contract.
|
||||
Thus `host_face: "max_z"` conveys placement intent, never output strings;
|
||||
derive a selector from advertised roles or use explicit coordinates.
|
||||
|
||||
Use millimetres and a right-handed coordinate system unless the request says
|
||||
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
||||
Do not turn an assumed dimension into a deterministic acceptance target.
|
||||
|
||||
## Modeling Brief
|
||||
|
||||
Identify explicit dimensions, datums, directions, primary volumes, removals,
|
||||
repetitions, finish features, targets, and noncritical assumptions. Explicit
|
||||
dimensions take precedence over inferred proportions. Clarify only when an
|
||||
omitted interface, scale, safety, or compliance value prevents construction;
|
||||
otherwise record a practical default in `assumptions`.
|
||||
|
||||
## Construction Order
|
||||
|
||||
Choose the simplest supported construction whose parameters directly express
|
||||
the requested dimensions. Use a stable order:
|
||||
|
||||
1. establish the body and functional coordinate frame;
|
||||
2. create primary additive volume(s);
|
||||
3. create any selector-hosted feature before its named source output is
|
||||
changed by a fuse, cut, shell, pattern, or finishing operation;
|
||||
4. add remaining bosses, ribs, and other major additive geometry;
|
||||
5. make remaining pockets, bores, and through features;
|
||||
6. apply patterns, then fillets and chamfers last.
|
||||
|
||||
Every feature must list each feature it actually uses in `depends_on`.
|
||||
Prefer one complete profile-driven feature for a planar silhouette. Use
|
||||
primitives when their axis, radius, and height directly express the part.
|
||||
For through cuts, choose the operation's through extent and make the tool
|
||||
cross the material; never rely on coincident faces or a guessed nearby face.
|
||||
Delay dress-up operations because they can alter downstream topology.
|
||||
|
||||
## Operation Semantics
|
||||
|
||||
Apply the following rules from the supplied operation contracts. They describe
|
||||
the CDSL language itself and do not prescribe a particular part design.
|
||||
|
||||
### Hard Emission Boundaries
|
||||
|
||||
Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is
|
||||
not expressed by document-local names, so it cannot provide a reliable
|
||||
model-facing construction path. Express additions with an additive operation
|
||||
whose declared result mode supports the intended connected result, and express
|
||||
removals with a cut operation and a sketch.
|
||||
|
||||
Prefer self-contained operations. An unavailable selector makes a hosted
|
||||
operation unavailable even if requested; use a selector-free construction that
|
||||
preserves geometry and record the method limitation. Every selector must pass
|
||||
the exact-role and active-source checks.
|
||||
|
||||
- A primitive axis starts at `origin_mm` and grows in the supplied positive
|
||||
`direction`. Heights and distances are positive magnitudes. A direction of
|
||||
`[0,0,-1]` therefore models travel toward `-Z` without a negative height.
|
||||
- A sketch has exactly one `profile`. A circle is one closed contour and a
|
||||
polygon is one simple, non-self-intersecting closed loop. Never concatenate
|
||||
disconnected circles or multiple polygon loops into one profile, and never
|
||||
repeat a loop's start point to begin another loop.
|
||||
- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`;
|
||||
`source_feature_ids` contains local names and the count includes the seed.
|
||||
If a repeated removal has no live host selector, use a selector-free cut seed
|
||||
plus circular `operation_mode: "remove"`. Otherwise use one valid feature
|
||||
per unrelated instance with its true dependency.
|
||||
- Treat every operation schema as independent. Do not copy a parameter name,
|
||||
enum value, or field shape from a similar operation into another operation;
|
||||
emit only fields present in that feature's supplied `params_schema`.
|
||||
- To extend an already connected solid, use an additive operation with the
|
||||
explicit `result_mode: "fuse"` when its contract provides that mode. The new
|
||||
profile must intersect the prior solid, and the feature depends on the prior
|
||||
feature. Do not turn touching or overlapping primitive additions into
|
||||
independent bodies followed by a boolean union.
|
||||
- A primitive operation whose authoring schema has no `result_mode: "fuse"`
|
||||
must be treated as a body-producing operation, not as a guaranteed extension
|
||||
of an existing connected result. Use such an operation as an initial or
|
||||
explicitly independent construction only. When the requested result must be
|
||||
one connected body, continue it through an additive operation that explicitly
|
||||
supports fusion rather than joining primitive outputs later with a boolean.
|
||||
- Use a through-cut operation with a profile that intersects the material for
|
||||
a through removal. Build all intended material before a removal that must
|
||||
cross it. Do not make a temporary tool body and call `boolean_bodies` for a
|
||||
removal expressible as a cut operation.
|
||||
- Do not introduce an auxiliary feature solely to construct or delete a tool
|
||||
for an operation that the contract already represents directly. A deletion
|
||||
is a state-changing operation, never a no-op cleanup step, and downstream
|
||||
dependencies must not rely on deleted geometry.
|
||||
- Use a selector-hosted operation only when its required source output role is
|
||||
available and the target geometry unambiguously lies on that output. Supply
|
||||
every required selector and parameter from the operation contract; otherwise
|
||||
choose a self-contained operation whose coordinates express the intent.
|
||||
- A face-hosted extrusion reuses its host boundary. For any different boundary,
|
||||
use a sketch-profile operation at an explicit workplane.
|
||||
- Selector provenance is live. Do not select a source changed by an intervening
|
||||
fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a
|
||||
later host; schedule host-dependent features before it or use coordinates.
|
||||
|
||||
For `max_z`/`min_z` placement intent, use an owner role such as
|
||||
`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never
|
||||
`max_z_face` or `min_z_face`; prefer explicit coordinates when available.
|
||||
|
||||
Use only supplied operations and their exact parameter schemas. Do not invent
|
||||
parameters, implicit booleans, or substitutes after a capability error.
|
||||
|
||||
## Semantic Annotation (meta and intent)
|
||||
|
||||
Every document carries semantic annotations for downstream training. They are
|
||||
purely descriptive: the compiler carries them through verbatim, geometry never
|
||||
depends on them, and they are never acceptance targets.
|
||||
|
||||
Document level — include `meta` with at least one of:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"description": "R8 rounded square bushing, 100×100×12, central Ø45 bore",
|
||||
"function": "Spacer sleeve over a Ø45 shaft; rounded corners for handling"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `description`: one sentence naming the part with its key specifications
|
||||
(≤60 characters). `function`: what the part does and where it fits
|
||||
(≤200 characters). Narratives are written in the request language; keys and
|
||||
controlled labels are English `snake_case`.
|
||||
|
||||
Feature level — attach `intent` to every feature:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": {
|
||||
"label": "shaft_passage",
|
||||
"summary": "Ø45 central through bore, concentric with the outer contour",
|
||||
"why": "The fitting face of the sleeve; diameter follows the mating shaft",
|
||||
"ties_to_requirement": "R2",
|
||||
"provenance": "authored"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `label`: one controlled vocabulary term in `snake_case` — for example
|
||||
`housing_blank`, `shaft_passage`, `fastener_hole`, `bolt_circle`,
|
||||
`tap_hole`, `counterbore_seat`, `countersink_seat`, `locating_pin_hole`,
|
||||
`bearing_seat`, `press_fit_boss`, `mounting_boss`, `mounting_foot`,
|
||||
`lifting_eye`, `slot_adjustment`, `coolant_channel`, `lubrication_gallery`,
|
||||
`vent_hole`, `drain_port`, `fluid_inlet`, `process_corner_relief`,
|
||||
`weld_prep`, `machining_setup_tab`, `inspection_access`,
|
||||
`stress_relief_fillet`, `stiffening_rib`, `weight_relief`,
|
||||
`mass_saving_pocket`, `load_path_flange`, `wall_thickness_transition`,
|
||||
`gear_teeth`, `rack_teeth`, `thread_drive`, `cam_track`, `bend_wing`,
|
||||
`cosmetic_surface`, `datum_plane_feature`, `datum_axis_feature`. The list
|
||||
is open: an accurate new `snake_case` term is valid, but prefer vocabulary.
|
||||
- `summary` (required, ≤80 characters): what it is plus the key parameters.
|
||||
Use parameterized wording (`M8`, `Ø75`, `R8`), never a restatement of the
|
||||
request prose.
|
||||
- `why` (optional, ≤400 characters): the functional reason this feature exists.
|
||||
- `ties_to_requirement` (optional): the requirement id from the task's
|
||||
requirements contract that this feature implements. Include it whenever the
|
||||
task carries requirement ids and the feature plainly implements one of them;
|
||||
omit it when the task has no requirement ids. Never invent an id.
|
||||
- `provenance`: always `"authored"` when the model writes it.
|
||||
- Do not invent fields inside `intent`, and never treat a mismatch between an
|
||||
annotation and geometry as acceptable — the label must match the feature
|
||||
actually constructed.
|
||||
- Geometry metrics (`meta.derived_metrics`: volumes, hole counts, per-feature
|
||||
deltas) are computed and backfilled by the server after rebuild. Never
|
||||
author or estimate them; if a document already carries `derived_metrics`,
|
||||
leave it untouched.
|
||||
|
||||
## Sketches And Coordinates
|
||||
|
||||
Keep a sketch to exactly `workplane` and `profile`. The workplane declares its
|
||||
origin, `x_dir`, and normal. Profile coordinates are local to that workplane.
|
||||
For primitive axes, `origin_mm` is the start-cap center and `direction` is the
|
||||
positive build direction. For a selector-hosted hole, position coordinates are
|
||||
world coordinates unless the operation contract explicitly says otherwise.
|
||||
|
||||
The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For
|
||||
every sketch operation, emit exactly this shape. `profile` is singular,
|
||||
circles use the requested `diameter_mm`, and the local center is `center_mm`:
|
||||
|
||||
```json
|
||||
{
|
||||
"workplane": {
|
||||
"origin_mm": [0, 0, 12],
|
||||
"x_dir": [1, 0, 0],
|
||||
"normal": [0, 0, 1]
|
||||
},
|
||||
"profile": {
|
||||
"type": "circle",
|
||||
"diameter_mm": 56,
|
||||
"center_mm": [0, 0]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any
|
||||
other key inside an Authoring sketch. The compiler derives Runtime radius and
|
||||
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
|
||||
and height directly express the requested geometry and no sketch is needed.
|
||||
|
||||
Name features for their geometric role, for example `primary_add`,
|
||||
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
|
||||
repair diagnostics readable; they are not server identities.
|
||||
|
||||
## Selectors
|
||||
|
||||
Operation metadata defines selector need, kind, cardinality, and server-side
|
||||
destination. Put only declarative selectors in `selectors`, never the
|
||||
destination field in `params`.
|
||||
|
||||
For an output face, use an exact local role embedded in `source` and state a
|
||||
unique match:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "face",
|
||||
"source": "source_feature.top_planar_face",
|
||||
"match": "unique"
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
|
||||
Runtime token. The compiler adds the selector source dependency; declare other
|
||||
true construction dependencies yourself.
|
||||
|
||||
`top_planar_face`/`end_face` mean a positive-direction cap;
|
||||
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
|
||||
host, never a similar face by location, index, or proximity.
|
||||
|
||||
For a cap, `source` MUST match
|
||||
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
|
||||
No other role spelling exists in Authoring CDSL. If the producer does not
|
||||
advertise one of these roles, it cannot be the selector source.
|
||||
|
||||
Translate descriptive maximum/minimum placement to an appropriate cap role;
|
||||
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
|
||||
top cap and `bottom_planar_face` for its bottom cap.
|
||||
|
||||
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
|
||||
and contain every position. Execute it before its source is replaced; otherwise
|
||||
use a supported self-contained construction or report the limitation.
|
||||
|
||||
## Acceptance And Repair
|
||||
|
||||
Put only user-requested measurable targets in `acceptance_targets`; leave
|
||||
inferred dimensions in `assumptions`. On repair return a complete document,
|
||||
preserve executed features unless diagnosed, and change unexecuted features
|
||||
only as needed. Read diagnostics literally; do not add IDs, weaken requested
|
||||
values, silently delete a failed feature, or guess a selector.
|
||||
|
||||
## Pre-Emission Check
|
||||
|
||||
Before returning the document, check every feature against these invariants:
|
||||
|
||||
1. The operation is in the supplied whitelist, its params use only the
|
||||
supplied closed schema, and its sketch/selector presence matches the
|
||||
operation contract. The document contains no `boolean_bodies` feature.
|
||||
2. Each reference is a local name, each dependency is necessary, and no
|
||||
operation relies on a deleted, transient, or merely assumed body.
|
||||
3. Each sketch contains one valid profile and a complete right-handed
|
||||
workplane; each dimension is positive where the contract requires a
|
||||
magnitude.
|
||||
4. Each selector source has the exact `<feature>.<output_role>` form. Its role
|
||||
is one advertised by that producer operation, not a descriptive synonym,
|
||||
coordinate extreme, or inferred face name. For a cap, verify the required
|
||||
four-role source regex character-for-character.
|
||||
5. Every requested removal intersects its intended material, every repeated
|
||||
feature has a valid seed reference, and every connected addition uses an
|
||||
operation whose contract explicitly supports the chosen result mode.
|
||||
6. Every feature carries an `intent` with a truthful `label`, a parameterized
|
||||
`summary` of at most 80 characters, and `provenance: "authored"`; the
|
||||
document carries `meta` with at least one of `description`/`function`.
|
||||
|
||||
If any invariant is false, revise the construction before emitting the single
|
||||
complete `cad.author.v1` document.
|
||||
@@ -51,3 +51,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
|
||||
value shapes in `cdsl_schema.json`. In particular, hole positions must be
|
||||
objects such as `{"mm": [u_mm, v_mm, w_mm]}`, never bare coordinate arrays.
|
||||
- Preserve the original CDSL and write revisions as separate artifacts.
|
||||
|
||||
## CADFS Engine Maintenance
|
||||
|
||||
When changing CADFS lowering, the CDSL schema, runtime, geometry adapter,
|
||||
selector binding, or their tests, update
|
||||
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md` in the same change.
|
||||
|
||||
- Record the end-to-end capability state, remaining semantic boundary, and
|
||||
regression evidence. A successful rebuild is not geometric similarity.
|
||||
- Update a capability to completed only when lowering, contract, execution,
|
||||
geometry, and regression coverage all exist.
|
||||
- The progress ledger is local development state and is intentionally ignored
|
||||
by Git. Do not add it to a commit.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Autonomous CAD protocol v3.
|
||||
"""Single-stage Authoring CDSL protocol.
|
||||
|
||||
The package intentionally separates policy from I/O. Delivery code composes
|
||||
these modules with adapters; it must not bypass the command handlers.
|
||||
"""
|
||||
|
||||
PROTOCOL_VERSION = "3.0"
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Infrastructure adapters for the v3 ports."""
|
||||
"""Infrastructure adapters for the single-stage ports."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
|
||||
"""Immutable single-stage artifacts with staging manifests and atomic publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,10 +8,9 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import CandidateStage
|
||||
from app.cad_agent.ports import StagingRevision
|
||||
|
||||
|
||||
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
|
||||
@@ -27,7 +26,7 @@ class FileArtifactStore:
|
||||
|
||||
def task_dir(self, task_id: str) -> Path:
|
||||
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
|
||||
raise ValueError("Invalid v3 task id")
|
||||
raise ValueError("Invalid CAD task id")
|
||||
return self.root / task_id
|
||||
|
||||
def artifact_path(self, task_id: str, relative_path: str) -> Path:
|
||||
@@ -43,7 +42,7 @@ class FileArtifactStore:
|
||||
) -> None:
|
||||
root = self.task_dir(task_id)
|
||||
(root / "documents").mkdir(parents=True, exist_ok=True)
|
||||
(root / "actions").mkdir(exist_ok=True)
|
||||
(root / "events").mkdir(exist_ok=True)
|
||||
(root / "revisions").mkdir(exist_ok=True)
|
||||
(root / ".staging").mkdir(exist_ok=True)
|
||||
source = root / "source-requirements.md"
|
||||
@@ -67,14 +66,14 @@ class FileArtifactStore:
|
||||
if images:
|
||||
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
|
||||
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
|
||||
"""Mirror committed SQLite events into an append-only JSONL audit log.
|
||||
|
||||
SQLite remains authoritative. Replaying this method after an
|
||||
interruption appends only missing committed sequences and rejects a
|
||||
divergent line instead of rewriting audit history.
|
||||
"""
|
||||
path = self._path(task_id, "actions/action-ledger.jsonl")
|
||||
path = self._path(task_id, "events/event-ledger.jsonl")
|
||||
existing: dict[int, dict[str, Any]] = {}
|
||||
if path.is_file():
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
@@ -83,18 +82,18 @@ class FileArtifactStore:
|
||||
value = json.loads(raw)
|
||||
sequence = value.get("sequence") if isinstance(value, dict) else None
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger contains an invalid sequence")
|
||||
raise ValueError("Event ledger contains an invalid sequence")
|
||||
existing[sequence] = value
|
||||
missing: list[str] = []
|
||||
for event in events:
|
||||
sequence = event.get("sequence")
|
||||
if not isinstance(sequence, int) or sequence < 1:
|
||||
raise ValueError("Action ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.action-ledger.v1", **event}
|
||||
raise ValueError("Event ledger event has an invalid sequence")
|
||||
entry = {"schema_version": "cad.event-ledger.v1", **event}
|
||||
previous = existing.get(sequence)
|
||||
if previous is not None:
|
||||
if previous != entry:
|
||||
raise ValueError("Action ledger diverges from committed SQLite event")
|
||||
raise ValueError("Event ledger diverges from committed SQLite event")
|
||||
continue
|
||||
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
if missing:
|
||||
@@ -103,7 +102,7 @@ class FileArtifactStore:
|
||||
handle.writelines(missing)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return "actions/action-ledger.jsonl"
|
||||
return "events/event-ledger.jsonl"
|
||||
|
||||
def write_source_index(
|
||||
self,
|
||||
@@ -136,9 +135,8 @@ class FileArtifactStore:
|
||||
"""Return immutable source paragraphs and attachment blocks.
|
||||
|
||||
Source identifiers are assigned only after this method returns, so
|
||||
callers cannot choose IDs. Attachment metadata is deliberately a
|
||||
closed, server-provided projection: it lets a reviewer trace the
|
||||
source without treating upload metadata as arbitrary LLM input.
|
||||
callers cannot choose IDs. Attachment metadata is a closed,
|
||||
server-provided projection rather than arbitrary model input.
|
||||
"""
|
||||
supplied = source_blocks
|
||||
if supplied is None:
|
||||
@@ -195,19 +193,6 @@ class FileArtifactStore:
|
||||
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
|
||||
]
|
||||
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
|
||||
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
|
||||
# State points to the immutable artifact used as program input. The
|
||||
# fixed name is a read-only convenience view for terminal tasks.
|
||||
return self.read_json(task_id, artifact_path or "requirements-contract.json")
|
||||
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
|
||||
if not invocation_id:
|
||||
return self.write_json_once(task_id, "requirements-contract.json", payload)
|
||||
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
|
||||
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._path(task_id, relative_path)
|
||||
if not path.is_file():
|
||||
@@ -225,36 +210,20 @@ class FileArtifactStore:
|
||||
self._write_once(path, text)
|
||||
return relative_path
|
||||
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
|
||||
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
|
||||
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
|
||||
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
|
||||
directory = root / stable_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json_once(directory / "input.json", payload)
|
||||
return CandidateStage(stable_id, str(directory.resolve()))
|
||||
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
|
||||
return str(self._stage_path(task_id, stage_id, ""))
|
||||
return StagingRevision(stable_id, str(directory.resolve()))
|
||||
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
self._write_json_once(path, payload)
|
||||
return relative_path
|
||||
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
|
||||
path = self._stage_path(task_id, stage_id, relative_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
|
||||
source = self._stage_path(task_id, stage_id, "")
|
||||
target = self._path(task_id, f"revisions/{revision_id}")
|
||||
if target.exists():
|
||||
@@ -262,9 +231,8 @@ class FileArtifactStore:
|
||||
if manifest is None:
|
||||
raise RuntimeError("Published revision has no valid manifest")
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
# Render/rebuild reports contain paths for the reviewer. Rebase those
|
||||
# paths while artifacts are still mutable staging output, so they point
|
||||
# at the revision after the atomic directory rename.
|
||||
# Rebase report paths while artifacts are still mutable staging output,
|
||||
# so they point at the revision after the atomic directory rename.
|
||||
self._rebase_staged_paths(source, target)
|
||||
manifest = self._create_manifest(source)
|
||||
self._write_json_once(source / "manifest.json", manifest)
|
||||
@@ -272,39 +240,6 @@ class FileArtifactStore:
|
||||
os.replace(source, target)
|
||||
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
|
||||
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
|
||||
"""Find a manifest-verified candidate already renamed before its DB CAS.
|
||||
|
||||
Publishing artifacts and advancing SQLite cannot share a transaction.
|
||||
The stage id persisted inside ``candidate.json`` makes a post-rename
|
||||
recovery deterministic and prevents another build or revision.
|
||||
"""
|
||||
revisions = self.task_dir(task_id) / "revisions"
|
||||
if not revisions.is_dir():
|
||||
return None
|
||||
for revision in sorted(revisions.iterdir()):
|
||||
if not revision.is_dir() or self._manifest(revision) is None:
|
||||
continue
|
||||
candidate_path = revision / "candidate.json"
|
||||
if not candidate_path.is_file():
|
||||
continue
|
||||
try:
|
||||
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
|
||||
return revision.name, candidate
|
||||
return None
|
||||
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
|
||||
root = self.task_dir(task_id) / ".staging"
|
||||
if not root.is_dir():
|
||||
return
|
||||
for directory in root.iterdir():
|
||||
if not directory.is_dir() or directory.name in referenced_stage_ids:
|
||||
continue
|
||||
shutil.rmtree(directory)
|
||||
|
||||
def _path(self, task_id: str, relative_path: str) -> Path:
|
||||
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
|
||||
raise ValueError("Invalid artifact relative path")
|
||||
@@ -316,7 +251,7 @@ class FileArtifactStore:
|
||||
|
||||
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
|
||||
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
|
||||
raise ValueError("Invalid candidate stage id")
|
||||
raise ValueError("Invalid staging revision id")
|
||||
root = self.task_dir(task_id).resolve()
|
||||
stage = (root / ".staging" / stage_id).resolve()
|
||||
if root not in stage.parents:
|
||||
@@ -351,13 +286,6 @@ class FileArtifactStore:
|
||||
temporary.write_bytes(data)
|
||||
os.replace(temporary, path)
|
||||
|
||||
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
|
||||
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
|
||||
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
|
||||
if not safe_invocation:
|
||||
raise ValueError("Artifact invocation ID is invalid")
|
||||
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
|
||||
|
||||
@classmethod
|
||||
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
|
||||
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
|
||||
@@ -371,8 +299,8 @@ class FileArtifactStore:
|
||||
relative = path.relative_to(directory).as_posix()
|
||||
files[relative] = sha256(path.read_bytes()).hexdigest()
|
||||
if not files:
|
||||
raise RuntimeError("Candidate staging directory has no artifacts")
|
||||
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
|
||||
raise RuntimeError("Staging revision has no artifacts")
|
||||
return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
|
||||
|
||||
@staticmethod
|
||||
def _manifest(directory: Path) -> dict[str, Any] | None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""In-process idempotent event delivery for the v3 delivery boundary."""
|
||||
"""In-process idempotent event delivery for the single-stage protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Independent rendered-review adapter for protocol v3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
|
||||
class RenderedReviewGateway:
|
||||
def __init__(self, models: StructuredModelGateway) -> None:
|
||||
self.models = models
|
||||
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Review tool is missing a name")
|
||||
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
|
||||
if kind in {"image_observation", "final"}:
|
||||
for raw_path in payload.get("reference_image_paths") or ():
|
||||
path = Path(str(raw_path))
|
||||
if path.is_file():
|
||||
content.append(self._image_part(path))
|
||||
if kind in {"candidate", "final"}:
|
||||
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||
for path in self._evidence_paths(manifest):
|
||||
content.append(self._image_part(path))
|
||||
return await self.models.call_tool(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
tool=tool,
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
required_tool_name=name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
|
||||
selected: list[Path] = []
|
||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||
if contact.is_file():
|
||||
selected.append(contact)
|
||||
wanted = {"top", "front", "right", "isometric"}
|
||||
for item in manifest.get("views") or ():
|
||||
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
|
||||
continue
|
||||
path = Path(str(item.get("path") or ""))
|
||||
if path.is_file():
|
||||
selected.append(path)
|
||||
if not selected:
|
||||
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
|
||||
return selected[:5]
|
||||
|
||||
@staticmethod
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
"""SQLite repository: the sole mutable state authority for protocol v3."""
|
||||
"""SQLite authority for ``cad.single-stage.v1`` tasks.
|
||||
|
||||
There is no reader or migration for the deleted feature-DAG protocol. Opening
|
||||
a legacy task database drops its task data before creating this schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -10,11 +13,13 @@ from threading import RLock
|
||||
from typing import Any, Iterator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState
|
||||
from app.cad_agent.ports import InvocationRecord
|
||||
|
||||
|
||||
class SqliteTaskRepository:
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.2 intentionally has no migration path from the
|
||||
# structured-only / review-loop task model. Deployment starts with
|
||||
# an empty task database, as those tasks do not have immutable
|
||||
# Markdown source artifacts to compile from.
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
DROP TABLE IF EXISTS tool_audits;
|
||||
DROP TABLE IF EXISTS usage_records;
|
||||
DROP TABLE IF EXISTS invocations;
|
||||
DROP TABLE IF EXISTS ledger;
|
||||
DROP TABLE IF EXISTS model_capabilities;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
""")
|
||||
connection.executescript(
|
||||
"""
|
||||
# This database is dedicated to CAD task state. An old
|
||||
# protocol has no safe reader or migration, so remove every
|
||||
# persisted task table and let SQLite remove their indexes.
|
||||
tables = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for row in tables:
|
||||
name = str(row[0]).replace('"', '""')
|
||||
connection.execute(f'DROP TABLE IF EXISTS "{name}"')
|
||||
connection.executescript(f"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '',
|
||||
pending_action_json TEXT,
|
||||
candidate_id TEXT NOT NULL DEFAULT '',
|
||||
candidate_stage_id TEXT NOT NULL DEFAULT '',
|
||||
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_spec_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id TEXT NOT NULL DEFAULT '',
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
|
||||
request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
|
||||
runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
|
||||
diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
|
||||
clarification_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invocations (
|
||||
invocation_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ledger (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
published_at TEXT
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, published_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tool_audits (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id,model_id,schema_hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
"INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
|
||||
(task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
raise RuntimeError("SQLite task insert was not visible")
|
||||
raise RuntimeError("Task was not persisted")
|
||||
return state
|
||||
|
||||
def get_state(self, task_id: str) -> TaskState | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
return self._state(row) if row is not None else None
|
||||
return self._state(row) if row else None
|
||||
|
||||
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
events = self.ledger_events(task_id)
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||
] if isinstance(frozen, dict) else []
|
||||
status_event = next((
|
||||
item for item in reversed(events)
|
||||
if item.get("event") in {
|
||||
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||
"no_progress_limit",
|
||||
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||
"failed_author_format", "runtime_contract_invalid",
|
||||
"completed_best_effort",
|
||||
}
|
||||
), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
# A later repair can end at authoring or compilation after an earlier
|
||||
# build already published an executable prefix. Its terminal event
|
||||
# intentionally contains diagnostics rather than a duplicate path
|
||||
# map, so keep the most recent event that actually delivered a
|
||||
# revision. Otherwise the completed projection loses the existing
|
||||
# STEP/GLB links exactly when best-effort publication matters most.
|
||||
result: dict[str, Any] = {}
|
||||
paths: dict[str, Any] = {}
|
||||
for item in reversed(events):
|
||||
if item.get("event") not in {
|
||||
"build_completed", "published_best_effort", "build_failed",
|
||||
}:
|
||||
continue
|
||||
candidate_paths = item.get("paths")
|
||||
if isinstance(candidate_paths, dict) and candidate_paths:
|
||||
result, paths = item, candidate_paths
|
||||
break
|
||||
return {
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
"state_version": state.version,
|
||||
"active_revision": state.active_revision,
|
||||
"current_revision": state.active_revision,
|
||||
"schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase), "state_version": state.version,
|
||||
"active_revision": state.active_revision, "current_revision": state.active_revision,
|
||||
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
|
||||
"pending_action": self._pending_payload(state.pending_action),
|
||||
"active_candidate_id": state.candidate_id,
|
||||
"repair_required": state.repair_required,
|
||||
"repair_count": state.repair_count, "repair_budget": 2,
|
||||
"last_error": state.last_error.value if state.last_error else "",
|
||||
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
"requirements_spec_path": state.requirements_spec_path,
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
|
||||
else "verified" if state.phase == TaskPhase.COMPLETED
|
||||
else "pending"
|
||||
),
|
||||
"verification_warnings": verification_warnings,
|
||||
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||
"questions": questions,
|
||||
"issues": issues,
|
||||
"blocker_type": (
|
||||
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
|
||||
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
|
||||
),
|
||||
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
|
||||
"action_ledger_summary": events[-12:],
|
||||
"revisions": revisions,
|
||||
"requirements_path": state.requirements_path, "authoring_path": state.authoring_path,
|
||||
"runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
|
||||
"diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
|
||||
"message": str(result.get("message") or ""),
|
||||
"revisions": ([{
|
||||
"revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
|
||||
"cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
|
||||
"glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
|
||||
}] if state.active_revision else []),
|
||||
"action_ledger_summary": events[-16:],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
|
||||
return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
|
||||
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def compare_and_swap(
|
||||
self,
|
||||
state: TaskState,
|
||||
*,
|
||||
events: list[dict[str, Any]] = (),
|
||||
invocation_id: str | None = None,
|
||||
invocation_result: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
|
||||
if (invocation_id is None) != (invocation_result is None):
|
||||
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
|
||||
previous_version = state.version - 1
|
||||
if previous_version < 0:
|
||||
raise ValueError("State version must advance exactly once")
|
||||
raise ValueError("Invocation completion requires both values")
|
||||
previous = state.version - 1
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||
state.candidate_id, state.candidate_stage_id,
|
||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
|
||||
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
)
|
||||
cursor = connection.execute("""
|
||||
UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
|
||||
requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE task_id=? AND state_version=?
|
||||
""", (
|
||||
state.phase.value, state.version, state.active_revision, state.repair_count,
|
||||
state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
|
||||
state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
for event in events:
|
||||
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
|
||||
for item in events:
|
||||
encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
if invocation_id is not None and invocation_result is not None:
|
||||
finished = connection.execute(
|
||||
"""UPDATE invocations
|
||||
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
|
||||
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
|
||||
)
|
||||
if finished.rowcount != 1:
|
||||
raise ValueError("Invocation is not an active record for this state transition")
|
||||
if invocation_id is not None:
|
||||
updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
|
||||
if updated.rowcount != 1:
|
||||
raise ValueError("Invocation is not active")
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
@@ -310,52 +195,41 @@ class SqliteTaskRepository:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
|
||||
if existing is not None:
|
||||
connection.execute("COMMIT")
|
||||
return self._invocation(existing)
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
|
||||
if row is None:
|
||||
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
|
||||
connection.execute("COMMIT")
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
connection.execute("COMMIT")
|
||||
return self._invocation(row)
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
|
||||
(task_id, invocation_id),
|
||||
).fetchone()
|
||||
return self._invocation(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
|
||||
return self._invocation(row) if row else None
|
||||
|
||||
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("Unknown invocation")
|
||||
if str(row["status"]) == "finished":
|
||||
# A winning concurrent command has already recorded the only
|
||||
# durable result for this idempotency key. Never overwrite it.
|
||||
return
|
||||
cursor = connection.execute(
|
||||
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
|
||||
(json.dumps(result, ensure_ascii=True), invocation_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValueError("Invocation could not be completed")
|
||||
connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
|
||||
|
||||
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
|
||||
args: tuple[Any, ...] = ()
|
||||
if task_id is not None:
|
||||
query += " AND task_id=?"
|
||||
args += (task_id,)
|
||||
query += " ORDER BY event_id LIMIT ?"
|
||||
args += (limit,)
|
||||
with self._lock, self._connection() as connection:
|
||||
if task_id is None:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
|
||||
else:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
|
||||
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute(query, args).fetchall()
|
||||
return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def mark_outbox_published(self, event_id: int) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
@@ -366,88 +240,47 @@ class SqliteTaskRepository:
|
||||
connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Persist a diagnostic structured-output audit separately from usage."""
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
|
||||
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
|
||||
)
|
||||
connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
|
||||
for row in rows
|
||||
]
|
||||
rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
|
||||
return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
|
||||
|
||||
def usage_summary(self, task_id: str) -> dict[str, Any]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
values = [json.loads(row["usage_json"]) for row in rows]
|
||||
return {
|
||||
"calls": len(values),
|
||||
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
|
||||
"records": values,
|
||||
}
|
||||
return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
|
||||
|
||||
def running_task_ids(self) -> list[str]:
|
||||
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
|
||||
placeholders = ", ".join("?" for _ in phases)
|
||||
terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
|
||||
rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
|
||||
return [str(row["task_id"]) for row in rows]
|
||||
|
||||
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
|
||||
|
||||
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
@staticmethod
|
||||
def _state(row: sqlite3.Row) -> TaskState:
|
||||
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
|
||||
pending = PendingAction(
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
active_revision=str(row["active_revision"] or ""), pending_action=pending,
|
||||
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
|
||||
repair_required=bool(row["repair_required"]),
|
||||
active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
|
||||
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||
requirements_spec_path=str(row["requirements_spec_path"] or ""),
|
||||
requirements_document_path=str(row["requirements_document_path"] or ""),
|
||||
completion_target_path=str(row["completion_target_path"] or ""),
|
||||
modeling_plan_path=str(row["modeling_plan_path"] or ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
|
||||
runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
|
||||
diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
|
||||
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
|
||||
return "failed"
|
||||
if phase == TaskPhase.CANCELLED:
|
||||
return "cancelled"
|
||||
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
||||
return phase.value.lower()
|
||||
if phase == TaskPhase.WAITING_FOR_USER:
|
||||
return "waiting_for_user"
|
||||
return "running"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||
"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
|
||||
|
||||
Raw tool arguments are intentionally preserved. Callers must run their
|
||||
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||
@@ -46,13 +46,67 @@ class StructuredModelGateway:
|
||||
|
||||
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]:
|
||||
provider, model = self._provider_model(provider_id, model_id)
|
||||
payload = self._payload(provider, model.id, messages, [tool], required_tool_name)
|
||||
response = await self._request(provider, payload)
|
||||
response, compatibility_mode = await self._request_tool_call(
|
||||
provider, model.id, messages, [tool], required_tool_name,
|
||||
)
|
||||
try:
|
||||
return self._normalized_response(provider, response)
|
||||
normalized = self._normalized_response(provider, response)
|
||||
normalized["usage"]["structured_compatibility_mode"] = compatibility_mode
|
||||
return normalized
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
|
||||
|
||||
async def _request_tool_call(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Keep forced tool choice unless a provider explicitly rejects thinking mode.
|
||||
|
||||
Some OpenAI-compatible gateways reject an otherwise valid forced
|
||||
``tool_choice`` whenever reasoning is enabled or implicit. This is a
|
||||
request compatibility issue rather than an author error. First retry
|
||||
without the client-requested reasoning option; only if that exact
|
||||
rejection remains do we allow automatic choice among the single tool
|
||||
already exposed to the model. The workflow's one-call/schema checks
|
||||
still reject free-form or wrong-tool responses.
|
||||
"""
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(provider, model_id, messages, tools, required_tool_name),
|
||||
), "forced"
|
||||
except StructuredModelError as first_error:
|
||||
if not self._thinking_tool_choice_rejection(first_error):
|
||||
raise
|
||||
try:
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
),
|
||||
), "reasoning_disabled"
|
||||
except StructuredModelError as second_error:
|
||||
if not self._thinking_tool_choice_rejection(second_error):
|
||||
raise
|
||||
return await self._request(
|
||||
provider,
|
||||
self._payload(
|
||||
provider, model_id, messages, tools, required_tool_name,
|
||||
include_reasoning=False,
|
||||
force_tool_name=False,
|
||||
),
|
||||
), "single_tool_auto"
|
||||
|
||||
@staticmethod
|
||||
def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool:
|
||||
message = str(error).casefold()
|
||||
return "thinking" in message and ("tool_choice" in message or "tool choice" in message)
|
||||
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
failures: list[dict[str, str]] = []
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
@@ -116,39 +170,12 @@ class StructuredModelGateway:
|
||||
|
||||
@staticmethod
|
||||
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Give capability probes the same selector fact production exposes.
|
||||
|
||||
A required opaque selector is not inferable from a generic request for
|
||||
a minimal example. Production fragment turns expose a topology token
|
||||
before the fragment tool, so the probe must do the same while still
|
||||
letting the provider generate every other schema field itself.
|
||||
"""
|
||||
"""Probe the two standalone structured documents used by this protocol."""
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
name = str(function.get("name") or "")
|
||||
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||
if selector_required:
|
||||
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||
instruction += (
|
||||
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||
)
|
||||
if name == "review_candidate":
|
||||
instruction += (
|
||||
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||
)
|
||||
elif name == "review_final":
|
||||
instruction += (
|
||||
" This is a final-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||
)
|
||||
if name == "write_authoring_cdsl":
|
||||
instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
|
||||
return [{"role": "system", "content": instruction}]
|
||||
|
||||
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||
@@ -190,20 +217,31 @@ class StructuredModelGateway:
|
||||
})
|
||||
return values
|
||||
|
||||
def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]:
|
||||
def _payload(
|
||||
self,
|
||||
provider: ProviderConfig,
|
||||
model_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
required_tool_name: str,
|
||||
*,
|
||||
include_reasoning: bool = True,
|
||||
force_tool_name: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.")
|
||||
request_options = provider.request_options if include_reasoning else {}
|
||||
if provider.api_style == "responses":
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools),
|
||||
"tool_choice": {"type": "function", "name": required_tool_name},
|
||||
**provider.request_options,
|
||||
"tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto",
|
||||
**request_options,
|
||||
}
|
||||
return payload
|
||||
return {
|
||||
"model": model_id, "messages": messages, "tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
|
||||
"temperature": 0, **provider.request_options,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto",
|
||||
"temperature": 0, **request_options,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Adapter exposing the pure verifier registry through the application port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
|
||||
|
||||
class RegistryVerifierExecutor:
|
||||
def __init__(self, registry: VerifierRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
|
||||
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
|
||||
return results
|
||||
@@ -1 +1 @@
|
||||
"""Application command handlers and context assembly for protocol v3."""
|
||||
"""Application workflow and compiler for the Authoring CDSL protocol."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
|
||||
|
||||
|
||||
class AuthoringCompileError(ValueError):
|
||||
def __init__(self, code: str, message: str, *, path: str = "") -> None:
|
||||
self.code, self.path = code, path
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthoringCompiler:
|
||||
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
|
||||
self.operation_contract = operation_contract
|
||||
self.version = version
|
||||
|
||||
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
try:
|
||||
validate_finite(raw)
|
||||
doc = AuthoringDocument.model_validate(raw)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
|
||||
source_features = [feature for body in doc.bodies for feature in body.features]
|
||||
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
|
||||
ordered = self._topological(doc, dependencies)
|
||||
# Identity follows the source document, while execution follows the
|
||||
# dependency graph. A dependency reordering must never renumber IDs.
|
||||
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
|
||||
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
|
||||
features: list[dict[str, Any]] = []
|
||||
sketches: list[dict[str, Any]] = []
|
||||
sketch_index = 0
|
||||
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
|
||||
sketch_ids_by_feature = {
|
||||
feature.name: f"sketch_{source_positions[feature.name]:03d}"
|
||||
for feature in source_features
|
||||
if feature.sketch is not None
|
||||
}
|
||||
for feature in ordered:
|
||||
try:
|
||||
contract = self.operation_contract(feature.operation)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
|
||||
params = self._references(
|
||||
deepcopy(feature.params), contract, feature_ids, feature.name,
|
||||
sketch_ids_by_feature=sketch_ids_by_feature,
|
||||
)
|
||||
self._validate_params(contract, params, feature.name)
|
||||
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
|
||||
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
|
||||
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
|
||||
required_selectors = fragment_shape["selector_tokens"] == "required"
|
||||
runtime_feature_selectors: list[dict[str, Any]] = []
|
||||
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
|
||||
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if not required_selectors and selectors:
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors:
|
||||
slot = str(selector_policy["slot"])
|
||||
if slot == "feature.selectors":
|
||||
runtime_feature_selectors = selectors
|
||||
elif slot.startswith("params."):
|
||||
parameter = slot.removeprefix("params.")
|
||||
if "." in parameter:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if parameter in params:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_FORBIDDEN_FIELD",
|
||||
f"{parameter} is server-injected from selectors",
|
||||
path=f"features.{feature.name}.params.{parameter}",
|
||||
)
|
||||
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
|
||||
else:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
output = {
|
||||
"id": feature_ids[feature.name], "atomic_id": feature.operation,
|
||||
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
|
||||
"params": params,
|
||||
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
|
||||
}
|
||||
needs_sketch = fragment_shape["sketch"] == "required"
|
||||
if needs_sketch and feature.sketch is None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
|
||||
if not needs_sketch and feature.sketch is not None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
|
||||
if feature.sketch is not None:
|
||||
sketch_index += 1
|
||||
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
|
||||
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
|
||||
output["sketch_id"] = sketch_id
|
||||
if feature.intent is not None:
|
||||
# Semantic annotation only: carried through for training data,
|
||||
# never consumed by runtime geometry or validation semantics.
|
||||
output["intent"] = feature.intent.model_dump(mode="json", exclude_none=True)
|
||||
features.append(output)
|
||||
document_meta: dict[str, Any] = {"unit": "mm"}
|
||||
if doc.meta is not None:
|
||||
document_meta.update(doc.meta.model_dump(mode="json", exclude_none=True))
|
||||
runtime = {
|
||||
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
|
||||
"part_id": "compiled", "meta": document_meta,
|
||||
"bodies": [
|
||||
{"id": body_ids[body.name], "name": body.name}
|
||||
for body in doc.bodies
|
||||
],
|
||||
"geometry": {"sketches": sketches}, "features": features,
|
||||
}
|
||||
digest = sha256(self._canonical_json(raw)).hexdigest()
|
||||
return runtime, {
|
||||
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
|
||||
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
|
||||
"sketch_ids": [item["id"] for item in sketches],
|
||||
"implicit_selector_dependencies": implicit_selector_dependencies,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""Make every declared selector source an auditable graph dependency.
|
||||
|
||||
A selector is already an explicit local source reference. Requiring the
|
||||
author to repeat that same edge in a second field only creates a
|
||||
formatting failure; it does not add CAD intent. The compiler therefore
|
||||
adds the direct source edge deterministically and records it in the
|
||||
audit. It never selects a substitute topology element.
|
||||
"""
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
dependencies: dict[str, list[str]] = {}
|
||||
implicit: dict[str, list[str]] = {}
|
||||
for feature in by_name.values():
|
||||
values = list(feature.depends_on)
|
||||
additions: list[str] = []
|
||||
for selector in feature.selectors:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in by_name:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID",
|
||||
f"unknown selector source: {selector.source}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if source not in values:
|
||||
values.append(source)
|
||||
additions.append(source)
|
||||
dependencies[feature.name] = values
|
||||
if additions:
|
||||
implicit[feature.name] = additions
|
||||
return dependencies, implicit
|
||||
|
||||
@staticmethod
|
||||
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
result: list[Any] = []
|
||||
visiting: set[str] = set()
|
||||
done: set[str] = set()
|
||||
def visit(name: str) -> None:
|
||||
if name in visiting:
|
||||
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
|
||||
if name in done:
|
||||
return
|
||||
visiting.add(name)
|
||||
for dependency in dependencies[name]:
|
||||
visit(dependency)
|
||||
visiting.remove(name); done.add(name); result.append(by_name[name])
|
||||
for body in doc.bodies:
|
||||
for feature in body.features:
|
||||
visit(feature.name)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in feature_ids:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
|
||||
source_feature = next(item for item in source_features if item.name == source)
|
||||
role = selector.source.split(".", 1)[1]
|
||||
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
|
||||
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Lower the small Authoring sketch language to the generic runtime form."""
|
||||
profile = sketch["profile"]
|
||||
if profile["type"] == "circle":
|
||||
profile = {
|
||||
"type": "circle",
|
||||
"center": profile["center_mm"],
|
||||
"radius_mm": float(profile["diameter_mm"]) / 2.0,
|
||||
}
|
||||
return {"workplane": sketch["workplane"], "profile": profile}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_role(role: str, operation: str) -> str:
|
||||
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
|
||||
return role
|
||||
family = (
|
||||
"extrude" if operation.startswith("extrude") else
|
||||
"sweep" if operation.startswith("sweep") else
|
||||
"loft" if operation.startswith("loft") else
|
||||
"cylinder" if operation == "cylinder_add" else ""
|
||||
)
|
||||
if role in {"top_planar_face", "end_face"} and family:
|
||||
return family + ".end"
|
||||
if role in {"bottom_planar_face", "start_face"} and family:
|
||||
return family + ".start"
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
|
||||
schema = contract.get("author_params_schema") or {"type": "object"}
|
||||
errors = list(Draft202012Validator(schema).iter_errors(params))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
path = ".".join(str(item) for item in first.absolute_path)
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
|
||||
|
||||
@staticmethod
|
||||
def _references(
|
||||
params: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
feature_ids: dict[str, str],
|
||||
feature_name: str,
|
||||
*,
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
params = AuthoringCompiler._rewrite_named_references(
|
||||
params, feature_ids, sketch_ids_by_feature, feature_name,
|
||||
)
|
||||
policy = contract.get("reference_policy") or {"mode": "none"}
|
||||
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
|
||||
return params
|
||||
key = str(policy["slot"]).removeprefix("params.")
|
||||
value = params.get(key)
|
||||
if isinstance(value, list):
|
||||
names = value
|
||||
elif value is None and policy["min_items"] == 0:
|
||||
names = []
|
||||
elif isinstance(value, str):
|
||||
names = [value]
|
||||
else:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
|
||||
runtime_ids = set(feature_ids.values())
|
||||
if not all(isinstance(item, str) and item in runtime_ids for item in names):
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
|
||||
if not policy["min_items"] <= len(names) <= policy["max_items"]:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_named_references(
|
||||
value: Any,
|
||||
feature_ids: dict[str, str],
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
consuming_feature: str,
|
||||
key: str = "",
|
||||
) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
item_key: AuthoringCompiler._rewrite_named_references(
|
||||
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
|
||||
)
|
||||
for item_key, item_value in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
AuthoringCompiler._rewrite_named_references(
|
||||
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if key == "profile_sketch_ids":
|
||||
if value not in sketch_ids_by_feature:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return sketch_ids_by_feature[value]
|
||||
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
|
||||
if value not in feature_ids:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return feature_ids[value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Strict model-facing Authoring CDSL contract.
|
||||
|
||||
This is intentionally separate from the runtime CDSL: model output contains
|
||||
only document-local names and declarative references. Runtime identities are
|
||||
allocated by :mod:`authoring_compiler`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
|
||||
_FORBIDDEN = {
|
||||
"id",
|
||||
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
|
||||
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
|
||||
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
|
||||
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
|
||||
"host_face", "mirror_plane",
|
||||
}
|
||||
|
||||
|
||||
def validation_error_code(error: Exception) -> str:
|
||||
"""Map strict model validation failures to a stable public diagnostic."""
|
||||
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
|
||||
|
||||
|
||||
class AuthorModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_internal_fields(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
found = sorted(
|
||||
key for key in value
|
||||
if isinstance(key, str)
|
||||
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
|
||||
)
|
||||
if found:
|
||||
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
|
||||
for nested in value.values():
|
||||
cls.reject_internal_fields(nested)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
cls.reject_internal_fields(item)
|
||||
return value
|
||||
|
||||
|
||||
class AuthorWorkplane(AuthorModel):
|
||||
"""A fully explicit local sketch frame in world millimetres."""
|
||||
|
||||
origin_mm: list[float] = Field(min_length=3, max_length=3)
|
||||
x_dir: list[float] = Field(min_length=3, max_length=3)
|
||||
normal: list[float] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
class AuthorCircleProfile(AuthorModel):
|
||||
"""A declarative circle expressed with the user-facing diameter."""
|
||||
|
||||
type: Literal["circle"]
|
||||
diameter_mm: float = Field(gt=0)
|
||||
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
|
||||
|
||||
|
||||
class AuthorPolygonProfile(AuthorModel):
|
||||
"""A closed polygon in the local sketch workplane."""
|
||||
|
||||
type: Literal["polygon"]
|
||||
vertices: list[list[float]] = Field(min_length=3)
|
||||
|
||||
@field_validator("vertices")
|
||||
@classmethod
|
||||
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
|
||||
if any(len(point) != 2 for point in value):
|
||||
raise ValueError("polygon vertices must have exactly two coordinates")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorSketch(AuthorModel):
|
||||
"""The only authoring sketch form currently accepted by the compiler."""
|
||||
|
||||
workplane: AuthorWorkplane
|
||||
profile: AuthorCircleProfile | AuthorPolygonProfile
|
||||
|
||||
|
||||
class SelectorIntent(AuthorModel):
|
||||
"""A local feature-output reference, never a Runtime selector token."""
|
||||
|
||||
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
|
||||
source: str = Field(
|
||||
min_length=3,
|
||||
max_length=160,
|
||||
description="A local feature output in the form <feature_name>.<output_role>.",
|
||||
)
|
||||
match: Literal["unique", "all"] = "unique"
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def require_feature_output_reference(cls, value: str) -> str:
|
||||
feature, separator, role = value.partition(".")
|
||||
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
|
||||
raise ValueError("selector source must be <feature_name>.<output_role>")
|
||||
return value
|
||||
|
||||
|
||||
class FeatureIntent(AuthorModel):
|
||||
"""Feature-level semantic annotation for training data.
|
||||
|
||||
Mirrors ``$defs/featureIntent`` in ``cdsl_schema.json``: purely
|
||||
descriptive, never read by the compiler for geometry decisions.
|
||||
"""
|
||||
|
||||
label: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_]{2,63}$")
|
||||
summary: str = Field(min_length=1, max_length=80)
|
||||
why: str | None = Field(default=None, min_length=1, max_length=400)
|
||||
ties_to_requirement: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,80}$")
|
||||
provenance: Literal["authored", "annotated", "imported"]
|
||||
|
||||
|
||||
class DocumentMeta(AuthorModel):
|
||||
"""Document-level semantic annotation (part description and function)."""
|
||||
|
||||
description: str | None = Field(default=None, min_length=1, max_length=60)
|
||||
function: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_at_least_one(self) -> "DocumentMeta":
|
||||
if self.description is None and self.function is None:
|
||||
raise ValueError("meta requires at least one of description or function")
|
||||
return self
|
||||
|
||||
|
||||
class AuthorFeature(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Only parameters from this feature operation's supplied params_schema.",
|
||||
)
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=32)
|
||||
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
|
||||
sketch: AuthorSketch | None = Field(
|
||||
default=None,
|
||||
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
|
||||
)
|
||||
intent: FeatureIntent | None = Field(
|
||||
default=None,
|
||||
description="Optional semantic annotation for training; never consumed by geometry.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorBody(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AuthoringDocument(AuthorModel):
|
||||
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
|
||||
units: str = Field(default="mm", pattern=r"^mm$")
|
||||
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
|
||||
assumptions: list[str] = Field(default_factory=list, max_length=64)
|
||||
meta: DocumentMeta | None = Field(
|
||||
default=None,
|
||||
description="Optional document-level semantic annotation (description/function).",
|
||||
)
|
||||
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
|
||||
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_symbols(self) -> "AuthoringDocument":
|
||||
validate_finite(self.model_dump(mode="python"))
|
||||
bodies = [b.name for b in self.bodies]
|
||||
if len(bodies) != len(set(bodies)):
|
||||
raise ValueError("duplicate body name")
|
||||
names: set[str] = set()
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if feature.name in names:
|
||||
raise ValueError(f"duplicate feature name: {feature.name}")
|
||||
names.add(feature.name)
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if len(feature.depends_on) != len(set(feature.depends_on)):
|
||||
raise ValueError(f"duplicate dependency: {feature.name}")
|
||||
if any(dep not in names for dep in feature.depends_on):
|
||||
missing = next(dep for dep in feature.depends_on if dep not in names)
|
||||
raise ValueError(f"unknown feature reference: {missing}")
|
||||
return self
|
||||
|
||||
|
||||
def validate_finite(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"non-finite number at {path}")
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
validate_finite(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
validate_finite(item, f"{path}[{index}]")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Prompt material for the model-facing Authoring CDSL contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_authoring_guidance() -> str:
|
||||
try:
|
||||
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
|
||||
except OSError:
|
||||
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
|
||||
@@ -6,62 +6,25 @@ from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
EmptyCommand,
|
||||
ImageObservation,
|
||||
MarkdownDocument,
|
||||
StatelessCandidateReview,
|
||||
StatelessGeometryConclusion,
|
||||
StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest,
|
||||
compiled_requirements_schema,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.application.authoring_contract import AuthoringDocument
|
||||
from app.cad_agent.application.workflow import RequirementsAnalysis
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
|
||||
|
||||
CapabilityRole = Literal["author", "reviewer"]
|
||||
CapabilityRole = Literal["author"]
|
||||
|
||||
|
||||
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
atomic_ids = list(runtime.supported_atomic_ids())
|
||||
if not atomic_ids:
|
||||
if not runtime.supported_atomic_ids():
|
||||
raise RuntimeError("Runtime has no operations for conformance")
|
||||
tools = [
|
||||
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
|
||||
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
|
||||
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
|
||||
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
|
||||
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||
return [
|
||||
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
|
||||
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
|
||||
]
|
||||
for atomic_id in atomic_ids:
|
||||
contract = runtime.operation_contract(atomic_id)
|
||||
tools.append(_tool(
|
||||
f"conformance_{atomic_id}",
|
||||
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
|
||||
))
|
||||
return tools
|
||||
|
||||
|
||||
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""Canonical schemas for every v3 LLM state-changing command.
|
||||
|
||||
Providers may parse structured output first, but this module validates the raw
|
||||
tool arguments a second time before a command reaches a handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Annotated, Any, Literal, TypeVar
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
|
||||
|
||||
class StrictDto(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
|
||||
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
|
||||
|
||||
|
||||
class AcceptanceClaimInput(StrictDto):
|
||||
claim_kind: Identifier
|
||||
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
||||
|
||||
|
||||
class SpecRequirementInput(StrictDto):
|
||||
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class MarkdownDocument(StrictDto):
|
||||
"""A frozen human-readable design artifact, never an executable payload."""
|
||||
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
||||
|
||||
|
||||
class CompiledRequirementInput(StrictDto):
|
||||
"""One verifier bundle for one server-parsed checklist item.
|
||||
|
||||
The checklist text, ordering, source bindings, and all identifiers are
|
||||
intentionally absent: the service owns them after Markdown is frozen.
|
||||
"""
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CompiledRequirementsSpec(StrictDto):
|
||||
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
# Kept only so an interrupted process with an already imported old tool schema
|
||||
# fails at the workflow boundary instead of failing module import. New v3.1
|
||||
# tasks never expose or accept this aggregate specification.
|
||||
class RequirementsSpec(StrictDto):
|
||||
outcome: Literal["ready"]
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
||||
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RequirementsClarification(StrictDto):
|
||||
outcome: Literal["clarification"]
|
||||
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
||||
question: Annotated[str, Field(min_length=1, max_length=500)]
|
||||
|
||||
|
||||
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
||||
pass
|
||||
|
||||
|
||||
class EmptyCommand(StrictDto):
|
||||
pass
|
||||
|
||||
|
||||
class NextAction(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
intent: ShortText
|
||||
# The server binds this list from every frozen checklist target. It is not
|
||||
# author input, so a five-item UI-era limit must not reject a valid task.
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
||||
atomic_id: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _requirement_ids_are_unique(self) -> "NextAction":
|
||||
if len(self.requirement_ids) != len(set(self.requirement_ids)):
|
||||
raise ValueError("requirement_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class StatelessNextAction(StrictDto):
|
||||
intent: ShortText
|
||||
operation: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
|
||||
class TopologyRequest(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class GeometryConclusion(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class RollbackCheckpoint(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessTopologyRequest(StrictDto):
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class StatelessGeometryConclusion(StrictDto):
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
|
||||
class StatelessRollbackCheckpoint(StrictDto):
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class ClaimCoverage(StrictDto):
|
||||
claim_id: Identifier
|
||||
status: Literal["pass", "pending", "fail", "not_applicable"]
|
||||
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class CandidateReview(StrictDto):
|
||||
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
|
||||
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class StatelessCandidateReview(StrictDto):
|
||||
verdict: Literal["accept", "reject"]
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class VisualClaimDecision(StrictDto):
|
||||
status: Literal["pass", "fail"]
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessFinalReview(StrictDto):
|
||||
verdict: Literal["pass", "repair"]
|
||||
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class ImageMeasurement(StrictDto):
|
||||
name: Annotated[str, Field(min_length=1, max_length=160)]
|
||||
value: float | None = None
|
||||
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class ImageObservation(StrictDto):
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
||||
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
||||
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
|
||||
|
||||
class FinalReview(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
||||
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = RequirementsAuthorOutput.model_json_schema()
|
||||
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
return schema
|
||||
|
||||
|
||||
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
||||
schema = CompiledRequirementsSpec.model_json_schema()
|
||||
definitions = schema.get("$defs", {})
|
||||
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
requirements = schema.get("properties", {}).get("requirements")
|
||||
if isinstance(requirements, dict):
|
||||
requirements["minItems"] = target_count
|
||||
requirements["maxItems"] = target_count
|
||||
return schema
|
||||
|
||||
|
||||
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
||||
"""Drop harmless compiler chatter before strict requirements validation.
|
||||
|
||||
``compile_requirements_spec`` is a compiler stage: the service only needs
|
||||
the ordered verifier bundles for the frozen checklist items. Real models
|
||||
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
||||
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
||||
fields are not executable and are not part of the frozen contract, so they
|
||||
should not abort a task before modeling starts.
|
||||
|
||||
The verifier ``expected`` payload is intentionally not sanitized here. It
|
||||
remains governed by the registry's strict per-claim schema because those
|
||||
values drive deterministic validation.
|
||||
"""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
requirements = value.get("requirements")
|
||||
sanitized: dict[str, Any] = {}
|
||||
if isinstance(requirements, list):
|
||||
sanitized_requirements: list[Any] = []
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
sanitized_requirements.append(requirement)
|
||||
continue
|
||||
item: dict[str, Any] = {}
|
||||
if "assumptions" in requirement:
|
||||
item["assumptions"] = requirement["assumptions"]
|
||||
if "acceptance_claims" in requirement:
|
||||
claims = requirement["acceptance_claims"]
|
||||
if isinstance(claims, list):
|
||||
item["acceptance_claims"] = [
|
||||
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
||||
if isinstance(claim, dict) else claim
|
||||
for claim in claims
|
||||
]
|
||||
else:
|
||||
item["acceptance_claims"] = claims
|
||||
sanitized_requirements.append(item)
|
||||
sanitized["requirements"] = sanitized_requirements
|
||||
else:
|
||||
sanitized["requirements"] = requirements
|
||||
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessNextAction.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["operation"] = {"enum": atomic_ids}
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
||||
schema = StatelessFinalReview.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
||||
if isinstance(visual, dict):
|
||||
visual["minItems"] = visual_claim_count
|
||||
visual["maxItems"] = visual_claim_count
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessRollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def topology_request_schema(working_head: str) -> dict[str, Any]:
|
||||
schema = TopologyRequest.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
return schema
|
||||
|
||||
|
||||
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||
schema = RollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
|
||||
definitions = schema.get("$defs", {})
|
||||
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
return
|
||||
properties = coverage.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["claim_id"] = {"enum": claim_ids}
|
||||
|
||||
|
||||
Dto = TypeVar("Dto", bound=StrictDto)
|
||||
|
||||
|
||||
def raw_arguments_hash(raw_arguments_json: str) -> str:
|
||||
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def json_depth(value: Any, current: int = 0) -> int:
|
||||
if isinstance(value, dict):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value.values())])
|
||||
if isinstance(value, list):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value)])
|
||||
return current
|
||||
|
||||
|
||||
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
|
||||
"""Bound and parse raw arguments before any schema-specific validation."""
|
||||
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
|
||||
try:
|
||||
value = json.loads(raw_arguments_json)
|
||||
except json.JSONDecodeError as error:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
|
||||
if not isinstance(value, dict):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
|
||||
if json_depth(value) > max_depth:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
|
||||
if _contains_non_finite_number(value):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
|
||||
return value
|
||||
|
||||
|
||||
def _contains_non_finite_number(value: Any) -> bool:
|
||||
if isinstance(value, float):
|
||||
return not math.isfinite(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_non_finite_number(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_non_finite_number(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
|
||||
"""Revalidate raw arguments against the current dynamic JSON Schema."""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
errors = [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
if errors:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
|
||||
return None
|
||||
|
||||
|
||||
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
|
||||
"""Parse raw tool arguments once and return field-level DTO failures safely."""
|
||||
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
try:
|
||||
return model.model_validate(value)
|
||||
except ValidationError as error:
|
||||
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
|
||||
|
||||
|
||||
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
|
||||
"""Require exactly one known tool call; no provider parser is trusted."""
|
||||
if len(tool_calls) != 1:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
|
||||
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
|
||||
name = str(function.get("name") or "") if isinstance(function, dict) else ""
|
||||
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
|
||||
if name != allowed_name:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
|
||||
return name, raw
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
"""Persist frozen documents and compile their checklist into a contract.
|
||||
|
||||
The model never names targets or internal objects during compilation. The
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
|
||||
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||
self._evaluation_capability_gaps[task_id] = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in validation_capability_gaps or () if isinstance(item, dict)
|
||||
]
|
||||
|
||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
claims = self._evaluation_contract_oracles.get(task_id)
|
||||
return None if claims is None else {
|
||||
"evaluation_only": True,
|
||||
"required_claims": deepcopy(claims),
|
||||
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
||||
return compiled_requirements_schema(
|
||||
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
||||
|
||||
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
||||
|
||||
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
||||
targets = self._checklist_items(task_id)
|
||||
if len(output.requirements) != len(targets):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
||||
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [
|
||||
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
||||
*compiler_warnings,
|
||||
]
|
||||
requirements: list[dict[str, Any]] = []
|
||||
claim_position = 1
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
||||
claims: list[dict[str, Any]] = []
|
||||
for claim in compiled.acceptance_claims:
|
||||
definition = self.registry.definition(claim.claim_kind)
|
||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
||||
claim_position += 1
|
||||
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
||||
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
try:
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
||||
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
||||
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||
visual = iter(review.get("visual_claims") or ())
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
statuses.append(str(result.get("status") or "unknown"))
|
||||
value = result.get("evidence")
|
||||
if value:
|
||||
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||||
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
||||
rows.extend(f" - Evidence: {value}" for value in evidence)
|
||||
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||
|
||||
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != phase:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
||||
errors = validator(document.markdown)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
||||
next_state = transition(state, event, **kwargs)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written})
|
||||
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def _checklist_items(self, task_id: str) -> list[str]:
|
||||
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
|
||||
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(output.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
return errors
|
||||
|
||||
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
||||
normalized = output.model_copy(deep=True)
|
||||
warnings: list[str] = []
|
||||
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
||||
normalized_claims = []
|
||||
for claim in requirement.acceptance_claims:
|
||||
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
||||
warnings.append(
|
||||
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
||||
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
||||
warnings.append(
|
||||
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
||||
)
|
||||
continue
|
||||
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
||||
normalized_claims.append(claim)
|
||||
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
||||
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
||||
return False
|
||||
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
||||
return False
|
||||
return any(
|
||||
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
||||
for item in claims
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_describes_finished_envelope(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return any(token in lowered for token in (
|
||||
"overall",
|
||||
"total",
|
||||
"finished part",
|
||||
"entire part",
|
||||
"whole part",
|
||||
"bounding box",
|
||||
"envelope",
|
||||
"总",
|
||||
"整体",
|
||||
"成品",
|
||||
"全高",
|
||||
"包围盒",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
||||
value = bbox_claim.expected.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
return
|
||||
for item in claims:
|
||||
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
||||
continue
|
||||
expected = getattr(item, "expected", None)
|
||||
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
||||
continue
|
||||
expected["axial_span_mm"] = value
|
||||
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
||||
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
||||
# Markdown is a human-facing semantic artifact. Its content is frozen
|
||||
# verbatim and is not executable, so headings are guidance for the
|
||||
# author rather than a server-enforced protocol.
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
||||
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
errors: list[dict[str, str]] = []
|
||||
if not values:
|
||||
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
||||
if len(values) != len(set(values)):
|
||||
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted:
|
||||
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
|
||||
|
||||
@staticmethod
|
||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
||||
|
||||
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Typed results returned by all command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.errors import WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Accepted:
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Waiting:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FailedInternal:
|
||||
correlation_id: str
|
||||
message: str
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Deterministic execution service for the single-stage Authoring protocol.
|
||||
|
||||
The service is deliberately synchronous and side-effect bounded so the HTTP
|
||||
workflow can call it after one author response (and at most two repairs).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
class SingleStageExecutor:
|
||||
MAX_REPAIRS = 2
|
||||
|
||||
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
|
||||
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
|
||||
|
||||
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compile once and persist immutable compiler inputs and output."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
|
||||
digest = self._digest(authoring)
|
||||
attempt = repair_count + 1
|
||||
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
|
||||
self.artifacts.write_json_once(task_id, audit_path, audit)
|
||||
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
|
||||
|
||||
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
|
||||
"""Build one compiled CDSL document and publish any executable prefix."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
digest = digest or self._digest(authoring)
|
||||
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
|
||||
"schema_version": "cad.single-stage.v1", "authoring": authoring,
|
||||
"runtime": runtime_cdsl, "compile_audit": audit,
|
||||
})
|
||||
try:
|
||||
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
|
||||
except (OSError, AdapterUnavailable):
|
||||
raise
|
||||
except Exception as error:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
|
||||
})
|
||||
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
|
||||
diagnostics = self._decorate_diagnostics(failures, audit)
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": diagnostics,
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
if rebuilt:
|
||||
revision = f"rev_{digest}"
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
|
||||
"schema_version": "cad.single-stage.staging-manifest.v1",
|
||||
"stage_id": stage.stage_id,
|
||||
"revision_id": revision,
|
||||
"repair_count": repair_count,
|
||||
"source_sha256": audit.get("source_sha256", ""),
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
|
||||
else:
|
||||
paths = {}
|
||||
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
|
||||
|
||||
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compatibility convenience for direct callers and focused tests."""
|
||||
compiled = self.compile(task_id, authoring, repair_count=repair_count)
|
||||
return {
|
||||
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
|
||||
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _digest(authoring: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
return sha256(encoded).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
names = {
|
||||
str(feature_id): str(name)
|
||||
for name, feature_id in (audit.get("feature_ids") or {}).items()
|
||||
if isinstance(name, str) and isinstance(feature_id, str)
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
for failure in failures:
|
||||
if not isinstance(failure, dict):
|
||||
continue
|
||||
feature_id = str(failure.get("feature_id") or "")
|
||||
result.append({**failure, "feature_name": names.get(feature_id, "")})
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Protocol v3 composition root. This is the only layer joining adapters."""
|
||||
"""Single-stage Authoring CDSL composition root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,62 +8,56 @@ import shutil
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
|
||||
from app.cad_agent.adapters.runtime import ProfileCadRuntime
|
||||
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
|
||||
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
|
||||
from app.cad_agent.application.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.application.single_stage import SingleStageExecutor
|
||||
from app.settings import Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class V3Services:
|
||||
class CadServices:
|
||||
repository: SqliteTaskRepository
|
||||
artifacts: FileArtifactStore
|
||||
workflow: WorkflowCoordinator
|
||||
models: StructuredModelGateway
|
||||
outbox: OutboxDispatcher
|
||||
single_stage: SingleStageExecutor
|
||||
|
||||
|
||||
def compose_v3(settings: Settings) -> V3Services:
|
||||
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
|
||||
if repository.protocol_reset and settings.task_root.exists():
|
||||
# Protocol 3.1 has no valid interpretation for structured-only task
|
||||
# artifacts, so clear that task root together with its old database.
|
||||
def compose_cad_services(settings: Settings) -> CadServices:
|
||||
database_root = settings.task_root.parent
|
||||
legacy_database = database_root / "autonomous-cad-v3.sqlite3"
|
||||
removed_legacy_database = False
|
||||
if legacy_database.exists():
|
||||
# The removed coordinator persisted incompatible task/action state in
|
||||
# its own database. Delete it as part of the deliberate destructive
|
||||
# migration, including SQLite sidecars if a worker stopped mid-write.
|
||||
for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))):
|
||||
if candidate.exists():
|
||||
candidate.unlink()
|
||||
removed_legacy_database = True
|
||||
repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3")
|
||||
protocol_reset = repository.protocol_reset or removed_legacy_database
|
||||
if protocol_reset and settings.task_root.exists():
|
||||
# Old task artifacts have no valid interpretation under the Authoring
|
||||
# protocol, so clear them together with the task database.
|
||||
shutil.rmtree(settings.task_root)
|
||||
if repository.protocol_reset:
|
||||
if protocol_reset:
|
||||
WorkspaceStore(settings).clear_current_task_references()
|
||||
artifacts = FileArtifactStore(settings.task_root)
|
||||
runtime = ProfileCadRuntime(settings)
|
||||
registry = default_registry()
|
||||
verifier = RegistryVerifierExecutor(registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||
fallbacks = tuple(
|
||||
ModelIdentity(provider.id, model.id)
|
||||
for provider in settings.providers
|
||||
if provider.configured
|
||||
for model in provider.models[:1]
|
||||
)
|
||||
models = StructuredModelGateway(settings)
|
||||
outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher())
|
||||
single_stage = SingleStageExecutor(repository, artifacts, runtime)
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(
|
||||
max_turns=max(8, settings.agent_tool_calls_per_cycle * 8),
|
||||
format_error_limit=settings.agent_format_error_repeat_limit,
|
||||
author_fallbacks=fallbacks,
|
||||
),
|
||||
WorkflowConfig(),
|
||||
repository,
|
||||
artifacts,
|
||||
runtime,
|
||||
models,
|
||||
RenderedReviewGateway(models),
|
||||
requirements,
|
||||
actions,
|
||||
single_stage,
|
||||
)
|
||||
return V3Services(repository, artifacts, workflow, models, outbox)
|
||||
return CadServices(repository, artifacts, workflow, models, outbox, single_stage)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pure domain objects and policies for protocol v3."""
|
||||
"""Pure domain objects and policies for the Authoring CDSL protocol."""
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .state import TaskPhase, TaskState, transition
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Canonical partial matching for acceptance-claim expectations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Allow an actual claim to add provider-chosen optional fields only."""
|
||||
if isinstance(required, dict):
|
||||
return isinstance(actual, dict) and all(
|
||||
key in actual and contains_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
)
|
||||
if isinstance(required, list):
|
||||
return isinstance(actual, list) and len(actual) == len(required) and all(
|
||||
contains_expected(actual_item, required_item)
|
||||
for actual_item, required_item in zip(actual, required, strict=True)
|
||||
)
|
||||
return actual == required
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Typed, serializable failures used across the v3 workflow."""
|
||||
"""Typed, serializable failures used across the single-stage workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,29 +9,25 @@ from typing import Any
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
CANCELLED = "CANCELLED"
|
||||
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
|
||||
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
|
||||
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
|
||||
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
|
||||
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
|
||||
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
|
||||
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
|
||||
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
|
||||
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
|
||||
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
|
||||
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
|
||||
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
|
||||
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
|
||||
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
|
||||
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
|
||||
STORAGE_FAILURE = "STORAGE_FAILURE"
|
||||
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
|
||||
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
|
||||
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
|
||||
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
|
||||
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
|
||||
FAILED_INTERNAL = "FAILED_INTERNAL"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
|
||||
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
|
||||
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
|
||||
AUTHOR_CYCLE = "AUTHOR_CYCLE"
|
||||
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
|
||||
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
|
||||
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
|
||||
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
|
||||
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
|
||||
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class FeatureNode(_StrictModel):
|
||||
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
priority: int = Field(ge=0, le=100_000)
|
||||
intent: str = Field(min_length=1, max_length=360)
|
||||
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=64)
|
||||
claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
expected_change: str = Field(min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_references(self) -> "FeatureNode":
|
||||
if len(self.depends_on) != len(set(self.depends_on)):
|
||||
raise ValueError("depends_on must not contain duplicates")
|
||||
if len(self.claim_ids) != len(set(self.claim_ids)):
|
||||
raise ValueError("claim_ids must not contain duplicates")
|
||||
if self.node_id in self.depends_on:
|
||||
raise ValueError("a feature node cannot depend on itself")
|
||||
return self
|
||||
|
||||
|
||||
class FeaturePlan(_StrictModel):
|
||||
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
|
||||
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
|
||||
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
|
||||
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_plan_fields(self) -> "FeaturePlan":
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
priorities = [node.priority for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id values must be unique")
|
||||
if len(priorities) != len(set(priorities)):
|
||||
raise ValueError("priority values must be unique")
|
||||
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
|
||||
raise ValueError("replaces_node_ids must not contain duplicates")
|
||||
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
|
||||
raise ValueError("final_claim_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
|
||||
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
|
||||
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
|
||||
result[claim["claim_id"]] = claim
|
||||
return result
|
||||
|
||||
|
||||
def validate_feature_plan(
|
||||
plan: FeaturePlan,
|
||||
contract: dict[str, Any],
|
||||
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
|
||||
*,
|
||||
previous_plan: FeaturePlan | None = None,
|
||||
completed_node_hashes: dict[str, str] | None = None,
|
||||
required_replacements: set[str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return stable, tool-facing validation errors for a frozen plan."""
|
||||
errors: list[dict[str, str]] = []
|
||||
nodes = {node.node_id: node for node in plan.nodes}
|
||||
known_atoms = set(atomic_ids)
|
||||
claims = contract_claims(contract)
|
||||
assigned: dict[str, str] = {}
|
||||
|
||||
for index, node in enumerate(plan.nodes):
|
||||
prefix = f"/nodes/{index}"
|
||||
if node.atomic_id not in known_atoms:
|
||||
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in nodes:
|
||||
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
|
||||
for claim_id in node.claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
continue
|
||||
if claim.get("verification_mode") != "deterministic":
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
|
||||
owner = assigned.setdefault(claim_id, node.node_id)
|
||||
if owner != node.node_id:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
|
||||
|
||||
final = set(plan.final_claim_ids)
|
||||
for claim_id in plan.final_claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
elif claim.get("verification_mode") == "deterministic":
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
|
||||
|
||||
for claim_id, claim in claims.items():
|
||||
deterministic = claim.get("verification_mode") == "deterministic"
|
||||
if deterministic and claim_id not in assigned:
|
||||
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
|
||||
if not deterministic and claim_id not in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
|
||||
if deterministic and claim_id in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
|
||||
|
||||
errors.extend(_cycle_errors(nodes))
|
||||
if previous_plan is None:
|
||||
if plan.parent_plan_hash:
|
||||
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
|
||||
if plan.replaces_node_ids:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
|
||||
else:
|
||||
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
|
||||
return errors
|
||||
|
||||
|
||||
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def walk(current: str) -> None:
|
||||
if current in visiting:
|
||||
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
|
||||
return
|
||||
if current in visited:
|
||||
return
|
||||
visiting.add(current)
|
||||
for dependency in nodes[current].depends_on:
|
||||
if dependency in nodes:
|
||||
walk(dependency)
|
||||
visiting.remove(current)
|
||||
visited.add(current)
|
||||
|
||||
for node_id in nodes:
|
||||
walk(node_id)
|
||||
return errors[:1]
|
||||
|
||||
|
||||
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
next_nodes = {node.node_id: node for node in plan.nodes}
|
||||
if plan.parent_plan_hash != plan_hash(previous):
|
||||
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
|
||||
replaced = set(plan.replaces_node_ids)
|
||||
if required_replacements and replaced != required_replacements:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
|
||||
for node_id, frozen_hash in completed.items():
|
||||
node = next_nodes.get(node_id)
|
||||
if node is None:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
|
||||
elif node_hash(node) != frozen_hash:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
|
||||
if node_id in replaced:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
|
||||
for node_id, old_node in old_nodes.items():
|
||||
if node_id in replaced:
|
||||
continue
|
||||
current = next_nodes.get(node_id)
|
||||
if current is None:
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
|
||||
elif node_hash(current) != node_hash(old_node):
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
|
||||
for node_id in replaced:
|
||||
if node_id not in old_nodes:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
|
||||
if node_id in next_nodes:
|
||||
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
|
||||
return errors
|
||||
|
||||
|
||||
class FeatureScheduler:
|
||||
"""Derive a plan's runnable node from immutable ledger evidence."""
|
||||
|
||||
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
|
||||
self.plan = plan
|
||||
self.events = events
|
||||
self._nodes = {node.node_id: node for node in plan.nodes}
|
||||
|
||||
def statuses(self) -> dict[str, str]:
|
||||
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
|
||||
continue
|
||||
if event.get("event") == "feature_node_verified":
|
||||
states[node_id] = "done"
|
||||
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
|
||||
states[node_id] = "invalidated"
|
||||
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
|
||||
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
|
||||
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
|
||||
states[node_id] = "running"
|
||||
for node in self.plan.nodes:
|
||||
if states[node.node_id] in {"done", "failed", "invalidated"}:
|
||||
continue
|
||||
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
|
||||
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
|
||||
states[node.node_id] = "blocked"
|
||||
elif all(value == "done" for value in dependency_states):
|
||||
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
|
||||
return states
|
||||
|
||||
def next_ready(self) -> FeatureNode | None:
|
||||
statuses = self.statuses()
|
||||
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
|
||||
return min(ready, key=lambda node: node.priority) if ready else None
|
||||
|
||||
def all_done(self) -> bool:
|
||||
return all(value == "done" for value in self.statuses().values())
|
||||
|
||||
def feature_ids(self) -> dict[str, str]:
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
result: dict[str, str] = {}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
feature_id = str(event.get("feature_id") or "")
|
||||
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
|
||||
result[node_id] = feature_id
|
||||
return result
|
||||
|
||||
def completed_node_hashes(self) -> dict[str, str]:
|
||||
statuses = self.statuses()
|
||||
return {
|
||||
node_id: node_hash(self._nodes[node_id])
|
||||
for node_id, status in statuses.items()
|
||||
if status == "done"
|
||||
}
|
||||
|
||||
def failure_count(self, node_id: str, failure_class: str) -> int:
|
||||
expected = node_hash(self._nodes[node_id])
|
||||
return sum(
|
||||
1
|
||||
for event in self.events
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and event.get("node_id") == node_id
|
||||
and event.get("node_hash") == expected
|
||||
and event.get("failure_class") == failure_class
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Versioned runtime operation contracts and dynamic fragment schemas."""
|
||||
"""Versioned runtime operation contracts for the Authoring compiler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
@@ -15,22 +14,6 @@ class OperationContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
SEMANTIC_PREFLIGHT_NAMES = frozenset({
|
||||
"sketch_workplane",
|
||||
"profile_non_self_intersecting",
|
||||
"host_face_exists",
|
||||
"hole_positions_on_host_plane",
|
||||
"cut_exit_distance",
|
||||
"requires_active_solid",
|
||||
"revolve_axis_on_sketch",
|
||||
"reference_plane_nonzero_normal",
|
||||
"reference_axis_nonzero_direction",
|
||||
"selected_edges_exist",
|
||||
"source_features_exist",
|
||||
"mirror_plane_exists",
|
||||
})
|
||||
|
||||
|
||||
def canonical_hash(value: dict[str, Any]) -> str:
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -55,12 +38,19 @@ def _is_closed(schema: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_authoring_schema_closed(schema: Any) -> bool:
|
||||
"""Whether an operation can safely be exposed in the Authoring whitelist."""
|
||||
return _is_closed(schema)
|
||||
|
||||
|
||||
def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||
required = {
|
||||
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
|
||||
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
|
||||
"runtime_capability",
|
||||
}
|
||||
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
|
||||
optional = {"contract_hash", "registry_revision", "nested_selector_policies"}
|
||||
if not required.issubset(contract) or set(contract) - required - optional:
|
||||
raise OperationContractError("Operation contract has unknown or missing fields")
|
||||
if not isinstance(contract["atomic_id"], str) or not contract["atomic_id"]:
|
||||
raise OperationContractError("Operation contract atomic_id is invalid")
|
||||
@@ -108,113 +98,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||
raise OperationContractError("Snapshot-bound reference policy is invalid")
|
||||
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
|
||||
slot = reference["slot"]
|
||||
if (
|
||||
not isinstance(slot, str)
|
||||
or (slot != "feature.selectors" and not slot.startswith("params."))
|
||||
or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"}
|
||||
or not isinstance(reference["min_items"], int)
|
||||
or not isinstance(reference["max_items"], int)
|
||||
or not 0 <= reference["min_items"] <= reference["max_items"] <= 64
|
||||
or reference["snapshot_bound"] is not True
|
||||
):
|
||||
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
|
||||
reference_name = reference["slot"].removeprefix("params.")
|
||||
if slot == "feature.selectors":
|
||||
if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]:
|
||||
raise OperationContractError("Feature selector reference policy disagrees with selector policy")
|
||||
else:
|
||||
reference_name = slot.removeprefix("params.")
|
||||
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||
reference_schema = properties.get(reference_name)
|
||||
required_params = params.get("required") if isinstance(params.get("required"), list) else []
|
||||
if (
|
||||
not isinstance(reference_schema, dict)
|
||||
or reference_schema.get("type") != "array"
|
||||
or not isinstance(reference_schema.get("items"), dict)
|
||||
or reference_name not in required_params
|
||||
):
|
||||
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
|
||||
if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}:
|
||||
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||
if reference_schema.get("type") == "string" and reference["max_items"] > 1:
|
||||
raise OperationContractError("Scalar snapshot-bound reference must allow at most one item")
|
||||
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
|
||||
raise OperationContractError("Operation semantic preflight is invalid")
|
||||
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
|
||||
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
|
||||
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]):
|
||||
raise OperationContractError("Operation semantic preflight names are duplicated")
|
||||
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate_verifiers are invalid")
|
||||
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate verifiers are duplicated")
|
||||
|
||||
|
||||
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]:
|
||||
"""Build the one-operation schema exposed for one pending action."""
|
||||
validate_operation_contract(contract)
|
||||
shape = contract["fragment_shape"]
|
||||
params = deepcopy(contract["author_params_schema"])
|
||||
reference = contract["reference_policy"]
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
slot = str(reference["slot"]).removeprefix("params.")
|
||||
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
|
||||
if not isinstance(items, dict):
|
||||
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||
items.clear()
|
||||
items.update({"enum": reference_tokens or []})
|
||||
feature_properties: dict[str, Any] = {
|
||||
"atomic_id": {"const": contract["atomic_id"]},
|
||||
"params": params,
|
||||
runtime_capability = contract["runtime_capability"]
|
||||
required_capability_flags = {
|
||||
"body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
|
||||
}
|
||||
feature_required = ["atomic_id", "params"]
|
||||
if shape["selector_tokens"] == "required":
|
||||
policy = contract["selector_policy"]
|
||||
feature_properties["selector_tokens"] = {
|
||||
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
|
||||
"maxItems": policy["max_items"], "uniqueItems": True,
|
||||
"description": (
|
||||
"Required author input. Copy the opaque selector token returned by the current topology "
|
||||
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
|
||||
"token into the host selector after schema validation."
|
||||
),
|
||||
}
|
||||
feature_required.append("selector_tokens")
|
||||
feature = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
|
||||
"rather than a server-filled params field."
|
||||
),
|
||||
"properties": feature_properties,
|
||||
"required": feature_required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
properties: dict[str, Any] = {"feature": feature}
|
||||
required = ["feature"]
|
||||
if shape["sketch"] == "required":
|
||||
properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"})
|
||||
required.insert(0, "sketch")
|
||||
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return schema
|
||||
|
||||
|
||||
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]:
|
||||
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum)
|
||||
return [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
|
||||
|
||||
def _point(size: int) -> dict[str, Any]:
|
||||
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
|
||||
|
||||
|
||||
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
|
||||
point2 = _point(2)
|
||||
point3 = _point(3)
|
||||
workplane = {
|
||||
"type": "object",
|
||||
"description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.",
|
||||
"properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)},
|
||||
"required": ["origin_mm", "x_dir", "normal"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if root_xy_datum:
|
||||
workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined."
|
||||
workplane["properties"] = {
|
||||
"origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3},
|
||||
"x_dir": {"const": [1, 0, 0]},
|
||||
"normal": {"const": [0, 0, 1]},
|
||||
}
|
||||
profile = {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
|
||||
]
|
||||
}
|
||||
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
|
||||
if (
|
||||
not isinstance(runtime_capability, dict)
|
||||
or set(runtime_capability) != required_capability_flags
|
||||
or not all(isinstance(value, bool) for value in runtime_capability.values())
|
||||
):
|
||||
raise OperationContractError("Operation runtime capability is invalid")
|
||||
|
||||
@@ -1,56 +1,24 @@
|
||||
"""Finite workflow state machine. This module has no persistence imports."""
|
||||
"""Finite state for the single-stage Authoring CDSL protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from .errors import ErrorCode, WorkflowError
|
||||
from .errors import ErrorCode
|
||||
|
||||
|
||||
class TaskPhase(StrEnum):
|
||||
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
|
||||
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
|
||||
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
|
||||
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
|
||||
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
|
||||
FEATURE_PENDING = "FEATURE_PENDING"
|
||||
FEATURE_BUILDING = "FEATURE_BUILDING"
|
||||
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
|
||||
# Legacy v3.1 phases remain readable only so an interrupted process can
|
||||
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
|
||||
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
|
||||
AWAITING_ACTION = "AWAITING_ACTION"
|
||||
ACTION_PENDING = "ACTION_PENDING"
|
||||
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
|
||||
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
|
||||
FINAL_VALIDATION = "FINAL_VALIDATION"
|
||||
WAITING_RETRY = "WAITING_RETRY"
|
||||
ANALYZING_REQUEST = "ANALYZING_REQUEST"
|
||||
AUTHORING_CDSL = "AUTHORING_CDSL"
|
||||
COMPILING_CDSL = "COMPILING_CDSL"
|
||||
BUILDING = "BUILDING"
|
||||
REPAIRING = "REPAIRING"
|
||||
PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
|
||||
WAITING_FOR_USER = "WAITING_FOR_USER"
|
||||
CANCELLED = "CANCELLED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PendingAction:
|
||||
action_id: str
|
||||
working_head: str
|
||||
intent: str
|
||||
requirement_ids: tuple[str, ...]
|
||||
atomic_id: str
|
||||
expected_change: str
|
||||
contract_hash: str
|
||||
idempotency_key: str
|
||||
node_id: str = ""
|
||||
plan_hash: str = ""
|
||||
claim_ids: tuple[str, ...] = ()
|
||||
depends_on_node_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
|
||||
# v3.2 code uses this alias to make the ownership boundary explicit.
|
||||
PendingFeature = PendingAction
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -59,167 +27,84 @@ class TaskState:
|
||||
phase: TaskPhase
|
||||
version: int
|
||||
active_revision: str = ""
|
||||
pending_action: PendingAction | None = None
|
||||
candidate_id: str = ""
|
||||
candidate_stage_id: str = ""
|
||||
repair_required: bool = False
|
||||
repair_count: int = 0
|
||||
last_error: ErrorCode | None = None
|
||||
retry_from_phase: TaskPhase | None = None
|
||||
requirements_spec_path: str = ""
|
||||
requirements_document_path: str = ""
|
||||
completion_target_path: str = ""
|
||||
modeling_plan_path: str = ""
|
||||
feature_plan_path: str = ""
|
||||
feature_plan_hash: str = ""
|
||||
feature_stage_id: str = ""
|
||||
requirements_path: str = ""
|
||||
authoring_path: str = ""
|
||||
runtime_cdsl_path: str = ""
|
||||
compile_audit_path: str = ""
|
||||
diagnostics_path: str = ""
|
||||
completion_path: str = ""
|
||||
clarification_path: str = ""
|
||||
requirements_contract_path: str = ""
|
||||
|
||||
@property
|
||||
def pending_feature(self) -> PendingFeature | None:
|
||||
return self.pending_action
|
||||
|
||||
@property
|
||||
def working_head(self) -> str:
|
||||
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||
|
||||
|
||||
# Legal state transitions. Events are intentionally terse persistence-neutral
|
||||
# names used by command handlers and architecture tests.
|
||||
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
|
||||
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
|
||||
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
|
||||
# exposes this event or accepts a Markdown plan from a model.
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
# User clarifications are durable task evidence. Resume on the same task
|
||||
# so its frozen request remains authoritative
|
||||
# instead of turning a clarification into a new CAD request.
|
||||
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
|
||||
# Runtime semantic preflight happens before candidate staging. It must
|
||||
# leave the checkpoint untouched and return control to the author for a
|
||||
# fresh observation/action decision, never masquerade as a format error.
|
||||
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
|
||||
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
|
||||
(TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
|
||||
(TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
|
||||
(TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
|
||||
(TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
|
||||
(TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
|
||||
(TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
|
||||
}
|
||||
_TRANSITIONS.update({
|
||||
(phase, "best_effort_completed"): TaskPhase.COMPLETED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "failed"): TaskPhase.FAILED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "cancelled"): TaskPhase.CANCELLED
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
|
||||
})
|
||||
_RETRY_RESUMABLE_PHASES = frozenset({
|
||||
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
TaskPhase.COMPILING_REQUIREMENTS,
|
||||
TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
TaskPhase.SCHEDULING_FEATURE,
|
||||
TaskPhase.FEATURE_PENDING,
|
||||
TaskPhase.FEATURE_BUILDING,
|
||||
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
TaskPhase.AWAITING_ACTION,
|
||||
TaskPhase.ACTION_PENDING,
|
||||
TaskPhase.CANDIDATE_BUILDING,
|
||||
TaskPhase.CANDIDATE_REVIEW,
|
||||
TaskPhase.FINAL_VALIDATION,
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
|
||||
for phase in _RETRY_RESUMABLE_PHASES
|
||||
})
|
||||
_TRANSITIONS.update({
|
||||
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
|
||||
for phase in TaskPhase
|
||||
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
|
||||
})
|
||||
for _phase in TaskPhase:
|
||||
if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
_TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
|
||||
_TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
|
||||
if _phase in {
|
||||
TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
|
||||
TaskPhase.BUILDING, TaskPhase.REPAIRING,
|
||||
}:
|
||||
_TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
|
||||
|
||||
|
||||
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
|
||||
"""Return a copy of the protocol transition table for architecture tests."""
|
||||
return dict(_TRANSITIONS)
|
||||
|
||||
|
||||
def retry_resume_event(state: TaskState) -> str | None:
|
||||
"""Return the only lossless resume event for a parked service failure."""
|
||||
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
|
||||
return None
|
||||
return f"resume_{state.retry_from_phase.value.lower()}"
|
||||
|
||||
|
||||
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
|
||||
"""Apply one legal transition and advance optimistic-concurrency version."""
|
||||
target = _TRANSITIONS.get((state.phase, event))
|
||||
def transition(
|
||||
state: TaskState,
|
||||
event: str,
|
||||
*,
|
||||
active_revision: str | None = None,
|
||||
repair_count: int | None = None,
|
||||
error: ErrorCode | None = None,
|
||||
requirements_path: str | None = None,
|
||||
authoring_path: str | None = None,
|
||||
runtime_cdsl_path: str | None = None,
|
||||
compile_audit_path: str | None = None,
|
||||
diagnostics_path: str | None = None,
|
||||
completion_path: str | None = None,
|
||||
clarification_path: str | None = None,
|
||||
) -> TaskState:
|
||||
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
|
||||
if target is None:
|
||||
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
|
||||
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
next_pending = None
|
||||
raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
|
||||
next_repairs = state.repair_count if repair_count is None else repair_count
|
||||
if not 0 <= next_repairs <= 2:
|
||||
raise ValueError("Single-stage repair count must be within [0, 2]")
|
||||
return replace(
|
||||
state,
|
||||
phase=target,
|
||||
version=state.version + 1,
|
||||
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||
pending_action=next_pending,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||
repair_count=next_repairs,
|
||||
last_error=error,
|
||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
|
||||
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
|
||||
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
|
||||
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
|
||||
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
|
||||
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
|
||||
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
|
||||
retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
|
||||
ErrorCode.RUNTIME_EXECUTION_FAILURE,
|
||||
} else None,
|
||||
requirements_path=state.requirements_path if requirements_path is None else requirements_path,
|
||||
authoring_path=state.authoring_path if authoring_path is None else authoring_path,
|
||||
runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
|
||||
compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
|
||||
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
|
||||
completion_path=state.completion_path if completion_path is None else completion_path,
|
||||
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
|
||||
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
|
||||
)
|
||||
|
||||
|
||||
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
|
||||
if supplied_head != state.working_head:
|
||||
return WorkflowError(
|
||||
ErrorCode.STALE_WORKING_HEAD,
|
||||
"The command was bound to an obsolete working head.",
|
||||
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Token Baseline Format
|
||||
|
||||
`python -m app.cad_agent.evals.live --suite release --require-live` requires
|
||||
`--baseline-report` to point at a measured protocol-2 report. It is not a
|
||||
fixture or an estimate. Record three independent runs of every release
|
||||
scenario with the same author request configuration and runtime profile.
|
||||
|
||||
| Field | Requirement |
|
||||
| --- | --- |
|
||||
| `schema_version` | `cad.token-baseline.v1` |
|
||||
| `protocol_version` | `2.0` |
|
||||
| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report |
|
||||
| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` |
|
||||
| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` |
|
||||
| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 |
|
||||
| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans |
|
||||
|
||||
The comparison uses `author_metric + plan_review_metric` for the v2 median and
|
||||
the v3 author metric only. It fails closed when provenance differs, any
|
||||
repetition is absent, completion/review/claim rates drop, plan-review calls
|
||||
remain, or the median reduction is below 30%.
|
||||
@@ -1 +1 @@
|
||||
"""Live, non-mocked protocol v3 release evaluations."""
|
||||
"""Local evaluation helpers for the single-stage Authoring protocol."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Create a resumable live-evaluation task without starting its workflow."""
|
||||
"""Create an isolated single-stage task without starting its workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,12 +9,12 @@ from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.composition import compose_cad_services
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
|
||||
parser = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--task-id")
|
||||
@@ -25,7 +25,7 @@ def main() -> int:
|
||||
arguments = _arguments()
|
||||
root = arguments.report_root.resolve()
|
||||
settings = get_settings()
|
||||
services = compose_v3(replace(
|
||||
services = compose_cad_services(replace(
|
||||
settings,
|
||||
task_root=root / "artifacts",
|
||||
conversation_root=root / "conversations",
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.comprehensive-prompt-fixtures.v1",
|
||||
"source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md",
|
||||
"source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R6 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "circular_flange_pcd",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}}
|
||||
],
|
||||
"required_atomic_ids": [],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"],
|
||||
"required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "square_flange",
|
||||
"units": "mm",
|
||||
"request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R8 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000
|
||||
},
|
||||
{
|
||||
"id": "counterbored_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}},
|
||||
{"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}},
|
||||
{"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_counterbore", "hole_wizard"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "countersunk_cover_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}},
|
||||
{"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_countersink", "hole_wizard"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "corner_radius", "description": "R5 outer corner radii"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "obround_slot_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"},
|
||||
{"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "t_slot_test_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"},
|
||||
{"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "rounded_rectangular_pocket",
|
||||
"units": "mm",
|
||||
"request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"},
|
||||
{"id": "pocket_corner_radius", "description": "R6 internal pocket corners"}
|
||||
],
|
||||
"max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000
|
||||
},
|
||||
{
|
||||
"id": "two_level_pocket_plate",
|
||||
"units": "mm",
|
||||
"request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"},
|
||||
{"id": "pocket_corner_radii", "description": "R5 on every internal corner"}
|
||||
],
|
||||
"max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000
|
||||
},
|
||||
{
|
||||
"id": "cross_drilled_valve_block",
|
||||
"units": "mm",
|
||||
"request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}},
|
||||
{"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "double_hole_linkage_arm",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"},
|
||||
{"id": "lightening_holes", "description": "three 14 mm central through holes"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "three_hole_linkage",
|
||||
"units": "mm",
|
||||
"request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}},
|
||||
{"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"},
|
||||
{"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"}
|
||||
],
|
||||
"max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_l_bracket",
|
||||
"units": "mm",
|
||||
"request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"},
|
||||
{"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"},
|
||||
{"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "u_bearing_support",
|
||||
"units": "mm",
|
||||
"request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"},
|
||||
{"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"}
|
||||
],
|
||||
"max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000
|
||||
},
|
||||
{
|
||||
"id": "double_lug_mount",
|
||||
"units": "mm",
|
||||
"request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"}
|
||||
],
|
||||
"max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000
|
||||
},
|
||||
{
|
||||
"id": "stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"},
|
||||
{"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
},
|
||||
{
|
||||
"id": "keyed_stepped_shaft",
|
||||
"units": "mm",
|
||||
"request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["extrude_cut_blind", "fillet"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"},
|
||||
{"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"},
|
||||
{"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "flanged_sleeve",
|
||||
"units": "mm",
|
||||
"request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}},
|
||||
{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"}
|
||||
],
|
||||
"max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000
|
||||
},
|
||||
{
|
||||
"id": "chamfered_bushing",
|
||||
"units": "mm",
|
||||
"request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}},
|
||||
{"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}},
|
||||
{"claim_kind": "visual", "expected": {}}
|
||||
],
|
||||
"required_atomic_ids": ["revolve_add"],
|
||||
"required_any_atomic_ids": ["chamfer"],
|
||||
"validation_capability_gaps": [
|
||||
{"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"}
|
||||
],
|
||||
"max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"schema_version": "cad.live-eval-fixtures.v1",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "rectangular_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"max_author_turns": 32,
|
||||
"max_reviewer_turns": 12,
|
||||
"max_total_calls": 44,
|
||||
"max_wall_seconds": 900,
|
||||
"max_total_tokens": 120000
|
||||
},
|
||||
{
|
||||
"id": "simple_flange",
|
||||
"units": "mm",
|
||||
"request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}},
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
},
|
||||
{
|
||||
"id": "ribbed_mounting_plate",
|
||||
"units": "mm",
|
||||
"request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind", "hole_blind"],
|
||||
"max_author_turns": 56,
|
||||
"max_reviewer_turns": 20,
|
||||
"max_total_calls": 76,
|
||||
"max_wall_seconds": 1500,
|
||||
"max_total_tokens": 200000
|
||||
},
|
||||
{
|
||||
"id": "selector_finish",
|
||||
"units": "mm",
|
||||
"request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.",
|
||||
"expected_phase": "COMPLETED",
|
||||
"required_claim_kinds": ["solid_count_equals"],
|
||||
"required_claims": [
|
||||
{"claim_kind": "solid_count_equals", "expected": {"value": 1}}
|
||||
],
|
||||
"required_atomic_ids": ["extrude_add_blind"],
|
||||
"required_any_atomic_ids": ["fillet", "chamfer"],
|
||||
"max_author_turns": 40,
|
||||
"max_reviewer_turns": 16,
|
||||
"max_total_calls": 56,
|
||||
"max_wall_seconds": 1200,
|
||||
"max_total_tokens": 160000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
"""Run the real provider, engine, reviewer and v3 state machine end to end.
|
||||
|
||||
``--require-live`` deliberately fails closed: missing credentials, blocked
|
||||
network, a skipped model capability, a timeout, or any scenario failure emits
|
||||
``LIVE_EVAL_BLOCKED``/failure details and exits non-zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.capabilities import verify_model_capability
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.cad_agent.domain.claim_matching import contains_expected
|
||||
from app.cad_agent.domain.operation_contract import canonical_hash
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.evals.token_baseline import (
|
||||
TokenBaselineError,
|
||||
author_request_identity,
|
||||
compare_token_baseline,
|
||||
load_token_baseline,
|
||||
profile_sha256,
|
||||
validate_token_baseline_provenance,
|
||||
)
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"candidate.json",
|
||||
"candidate-review.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.glb",
|
||||
"model.topology.json",
|
||||
"rebuild-report.json",
|
||||
"renders/render-manifest.json",
|
||||
"renders/contact-sheet.jpg",
|
||||
})
|
||||
|
||||
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
|
||||
# It deliberately has no candidate review or technical render bundle; the
|
||||
# final review owns that later evidence. GLB is optional because a preview
|
||||
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
|
||||
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
|
||||
"input.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.topology.json",
|
||||
"node-verification.json",
|
||||
"rebuild-report.json",
|
||||
})
|
||||
|
||||
_FAILURE_LAYERS = frozenset({
|
||||
"model_format_or_decision",
|
||||
"v3_contract_or_verifier",
|
||||
"cdsl_expression",
|
||||
"engine_execution",
|
||||
"independent_visual_review",
|
||||
"configuration_or_network",
|
||||
})
|
||||
|
||||
_ERROR_FAILURE_LAYERS = {
|
||||
"AUTHOR_FORMAT_INVALID": "model_format_or_decision",
|
||||
"AUTHOR_DECISION_REJECTED": "model_format_or_decision",
|
||||
"STALE_WORKING_HEAD": "model_format_or_decision",
|
||||
"FAILED_AUTHOR_FORMAT": "model_format_or_decision",
|
||||
"MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision",
|
||||
"RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier",
|
||||
"RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier",
|
||||
"VERIFIER_UNAVAILABLE": "v3_contract_or_verifier",
|
||||
"CLAIM_VERIFICATION_FAILED": "cdsl_expression",
|
||||
"CANDIDATE_BUILD_FAILED": "engine_execution",
|
||||
"CANDIDATE_REVIEW_REJECTED": "independent_visual_review",
|
||||
"AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network",
|
||||
"REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"RENDER_SERVICE_UNAVAILABLE": "configuration_or_network",
|
||||
"STORAGE_FAILURE": "configuration_or_network",
|
||||
"LIVE_EVAL_TIMEOUT": "configuration_or_network",
|
||||
"FAILED_INTERNAL": "engine_execution",
|
||||
}
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.")
|
||||
parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke")
|
||||
parser.add_argument("--require-live", action="store_true")
|
||||
parser.add_argument("--allow-skip", action="store_true")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--scenario", help="Run one fixture scenario by its stable ID for targeted regression validation.")
|
||||
parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _fixture(suite: str, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
||||
fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json"
|
||||
value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8"))
|
||||
if fixture_name == "comprehensive.json":
|
||||
source_document = str(value.get("source_document") or "")
|
||||
expected_digest = str(value.get("source_document_sha256") or "")
|
||||
source_path = (BACKEND_ROOT.parent / source_document).resolve()
|
||||
workspace_root = BACKEND_ROOT.parent.resolve()
|
||||
if (
|
||||
not source_document
|
||||
or workspace_root not in source_path.parents
|
||||
or not source_path.is_file()
|
||||
or sha256(source_path.read_bytes()).hexdigest() != expected_digest
|
||||
):
|
||||
raise ValueError("Comprehensive fixture is not synchronized with its source document.")
|
||||
values = [item for item in value.get("scenarios") or () if isinstance(item, dict)]
|
||||
values = values[:2] if suite == "smoke" else values
|
||||
if scenario_id is None:
|
||||
return values
|
||||
selected = [item for item in values if str(item.get("id") or "") == scenario_id]
|
||||
if not selected:
|
||||
raise ValueError(f"Unknown scenario {scenario_id!r} for suite {suite!r}")
|
||||
return selected
|
||||
|
||||
|
||||
def _rejection_codes(events: list[dict[str, Any]]) -> list[str]:
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else None
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}:
|
||||
codes.append(str(code))
|
||||
return codes
|
||||
|
||||
|
||||
def _acceptance_coverage(
|
||||
scenario: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a fixture's acceptance contract is representable today.
|
||||
|
||||
Comprehensive prompts deliberately include manufacturing relationships that
|
||||
may not yet have a deterministic verifier. A healthy-looking solid is
|
||||
not evidence for those relationships, so they remain explicit capability
|
||||
gaps instead of silently passing a scenario.
|
||||
"""
|
||||
actual = [
|
||||
{
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {},
|
||||
}
|
||||
for requirement in (requirements_contract or {}).get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
]
|
||||
required = {str(value) for value in scenario.get("required_claim_kinds") or ()}
|
||||
expected_claims = [
|
||||
{
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"expected": item.get("expected") if isinstance(item.get("expected"), dict) else {},
|
||||
}
|
||||
for item in scenario.get("required_claims") or ()
|
||||
if isinstance(item, dict) and isinstance(item.get("claim_kind"), str)
|
||||
]
|
||||
expected_kinds = {claim["claim_kind"] for claim in expected_claims}
|
||||
expected_claims.extend(
|
||||
{"claim_kind": claim_kind, "expected": {}}
|
||||
for claim_kind in sorted(required - expected_kinds)
|
||||
)
|
||||
gaps = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in scenario.get("validation_capability_gaps") or ()
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
missing = [
|
||||
claim for claim in expected_claims
|
||||
if not any(
|
||||
actual_claim["claim_kind"] == claim["claim_kind"]
|
||||
and _contains_business_expected(actual_claim["expected"], claim["expected"])
|
||||
for actual_claim in actual
|
||||
)
|
||||
]
|
||||
return {
|
||||
"required_claim_kinds": sorted(required),
|
||||
"covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})),
|
||||
"required_claims": expected_claims,
|
||||
"missing_claims": missing,
|
||||
"validation_capability_gaps": gaps,
|
||||
"complete": not missing and not gaps,
|
||||
}
|
||||
|
||||
|
||||
def _contains_business_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match fixture business values without coupling to verifier tolerances.
|
||||
|
||||
Tolerances are executable verifier parameters chosen within the schema's
|
||||
safe range. They are not a separate user requirement and should not make
|
||||
a valid generated contract fail release evaluation merely because the
|
||||
author used the registry default instead of the fixture's tighter value.
|
||||
"""
|
||||
if isinstance(actual, dict) and isinstance(required, dict):
|
||||
return all(
|
||||
key in actual
|
||||
and _contains_business_expected(actual[key], value)
|
||||
for key, value in required.items()
|
||||
if key not in {"tolerance_mm", "tolerance"}
|
||||
)
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _contains_expected(actual: Any, required: Any) -> bool:
|
||||
"""Match canonical claim values in release evaluation."""
|
||||
return contains_expected(actual, required)
|
||||
|
||||
|
||||
def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Classify a failed conformance probe without confusing outages for gaps.
|
||||
|
||||
A provider transport failure means the probe did not establish either
|
||||
support or non-support. Only a complete response that violates one of the
|
||||
exposed tool contracts is evidence of a model structured-output limit.
|
||||
"""
|
||||
messages = [
|
||||
str(failure.get("message") or "").casefold()
|
||||
for report in reports
|
||||
for failure in report.get("failures") or ()
|
||||
if isinstance(failure, dict)
|
||||
]
|
||||
unavailable_markers = (
|
||||
"transport unavailable",
|
||||
"connection",
|
||||
"network",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
)
|
||||
if any(any(marker in message for marker in unavailable_markers) for message in messages):
|
||||
return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network"
|
||||
return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision"
|
||||
|
||||
|
||||
def _artifact_evidence_complete(
|
||||
artifact_root: Path,
|
||||
revisions: list[str],
|
||||
active_revision: str,
|
||||
artifact_manifest: dict[str, Any] | None,
|
||||
ledger: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Require every published CAD decision to retain its reviewable evidence.
|
||||
|
||||
A non-empty report manifest is not enough: a task could otherwise report
|
||||
only a rendered contract view while silently losing the STEP, topology, or
|
||||
node verification used to accept a revision. Checkpoint manifests protect
|
||||
immutable build inputs and outputs; the report manifest additionally
|
||||
protects the frozen contract and final independent review written later.
|
||||
"""
|
||||
if not revisions or not active_revision:
|
||||
return False
|
||||
report_files = {
|
||||
str(item.get("path") or "")
|
||||
for item in (artifact_manifest or {}).get("files") or ()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
required_report_files = {
|
||||
"requirements-contract.json",
|
||||
f"reviews/final/{active_revision}/final-review.json",
|
||||
}
|
||||
if artifact_manifest is not None and not required_report_files.issubset(report_files):
|
||||
return False
|
||||
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||
return False
|
||||
feature_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
|
||||
}
|
||||
for revision_id in sorted(set(revisions)):
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
evidence_files = (
|
||||
_FEATURE_NODE_EVIDENCE_FILES
|
||||
if revision_id in feature_revisions
|
||||
else _LEGACY_CANDIDATE_EVIDENCE_FILES
|
||||
)
|
||||
required_paths = {revision_root / relative for relative in evidence_files}
|
||||
manifest_path = revision_root / "manifest.json"
|
||||
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||
return False
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
|
||||
return False
|
||||
for relative, digest in declared.items():
|
||||
path = revision_root / str(relative)
|
||||
if not isinstance(digest, str) or len(digest) != 64 or not path.is_file():
|
||||
return False
|
||||
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
if artifact_manifest is not None:
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
|
||||
if not expected_report_paths.issubset(report_files):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _failure_attribution(
|
||||
*,
|
||||
checks: dict[str, bool],
|
||||
outcome: str,
|
||||
events: list[dict[str, Any]],
|
||||
projection: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return one evidence-backed failure layer required by the test target."""
|
||||
if outcome == "passed":
|
||||
return None
|
||||
codes: list[str] = []
|
||||
for event in events:
|
||||
payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else payload
|
||||
code = result.get("code") if isinstance(result, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
codes.append(code)
|
||||
last_error = projection.get("last_error") if isinstance(projection, dict) else ""
|
||||
if isinstance(last_error, str) and last_error:
|
||||
codes.append(last_error)
|
||||
for code in reversed(codes):
|
||||
layer = _ERROR_FAILURE_LAYERS.get(code)
|
||||
if layer:
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": code,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": [name for name, passed in checks.items() if not passed],
|
||||
}
|
||||
failed_checks = [name for name, passed in checks.items() if not passed]
|
||||
if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks):
|
||||
layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP"
|
||||
elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks):
|
||||
layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED"
|
||||
elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED"
|
||||
elif "raw_argument_audit" in failed_checks:
|
||||
layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED"
|
||||
elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks):
|
||||
layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE"
|
||||
else:
|
||||
layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH"
|
||||
return {
|
||||
"layer": layer,
|
||||
"reason_code": reason,
|
||||
"evidence_codes": list(dict.fromkeys(codes)),
|
||||
"failed_checks": failed_checks,
|
||||
}
|
||||
|
||||
|
||||
def _run_checks(
|
||||
scenario: dict[str, Any],
|
||||
projection: dict[str, Any],
|
||||
usage: dict[str, Any],
|
||||
requirements_contract: dict[str, Any] | None,
|
||||
events: list[dict[str, Any]],
|
||||
artifact_root: Path,
|
||||
ledger: list[dict[str, Any]] | None = None,
|
||||
tool_audits: list[dict[str, Any]] | None = None,
|
||||
artifact_manifest: dict[str, Any] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||
published = [
|
||||
item for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
|
||||
]
|
||||
revisions = [str(item.get("revision_id") or "") for item in published]
|
||||
scheduled_atomic_ids = {
|
||||
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
|
||||
for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
|
||||
}
|
||||
operation_ids = {
|
||||
str(
|
||||
item.get("actual_atomic_id")
|
||||
or item.get("atomic_id")
|
||||
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
|
||||
)
|
||||
for item in published
|
||||
}
|
||||
required_operation_groups = [
|
||||
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||
if isinstance(group, list)
|
||||
]
|
||||
active_revision = str(projection.get("active_revision") or "")
|
||||
manifest = artifact_root / "revisions" / active_revision / "manifest.json"
|
||||
jsonl = artifact_root / "actions" / "action-ledger.jsonl"
|
||||
final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), [])
|
||||
audit_records = tool_audits if isinstance(tool_audits, list) else []
|
||||
audit_valid = bool(audit_records) and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("raw_arguments_hash"), str)
|
||||
and len(item["raw_arguments_hash"]) == 64
|
||||
and item.get("canonical_schema_valid") is True
|
||||
and isinstance(item.get("state_binding"), dict)
|
||||
and bool(item["state_binding"].get("phase"))
|
||||
and item["state_binding"].get("binding_valid") is True
|
||||
and item.get("single_allowed_call") is True
|
||||
for item in audit_records
|
||||
)
|
||||
acceptance = _acceptance_coverage(scenario, requirements_contract)
|
||||
artifact_evidence = _artifact_evidence_complete(
|
||||
artifact_root,
|
||||
revisions,
|
||||
active_revision,
|
||||
artifact_manifest,
|
||||
audit_ledger,
|
||||
)
|
||||
return {
|
||||
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||
"token_budget": total_tokens <= int(scenario["max_total_tokens"]),
|
||||
"call_budget": len(records) <= int(scenario["max_total_calls"]),
|
||||
"author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]),
|
||||
"reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]),
|
||||
"required_claims": not acceptance["missing_claims"],
|
||||
"acceptance_contract_coverage": acceptance["complete"],
|
||||
"required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids),
|
||||
"required_operation_alternative": (
|
||||
(not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids)))
|
||||
and all(group.intersection(operation_ids) for group in required_operation_groups)
|
||||
),
|
||||
"immutable_artifacts": bool(active_revision) and manifest.is_file(),
|
||||
"required_artifact_evidence": artifact_evidence,
|
||||
"artifact_manifest": (
|
||||
artifact_manifest is None
|
||||
or (
|
||||
isinstance(artifact_manifest.get("files"), list)
|
||||
and bool(artifact_manifest["files"])
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("path"), str)
|
||||
and isinstance(item.get("sha256"), str)
|
||||
and len(item["sha256"]) == 64
|
||||
for item in artifact_manifest["files"]
|
||||
)
|
||||
)
|
||||
),
|
||||
"action_ledger": jsonl.is_file(),
|
||||
"unique_revisions": len(revisions) == len(set(revisions)),
|
||||
"deterministic_claims_pass": bool(final_claims) and all(
|
||||
not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass"
|
||||
for item in final_claims
|
||||
),
|
||||
"raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records),
|
||||
"no_schema_or_decision_rejections": not _rejection_codes(events),
|
||||
}
|
||||
|
||||
|
||||
def _git_revision() -> str:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True,
|
||||
text=True, check=True, timeout=5,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Persist event metadata without prompts, tool arguments, or tool facts."""
|
||||
audit: list[dict[str, Any]] = []
|
||||
for item in events:
|
||||
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||||
audit.append({
|
||||
"name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""),
|
||||
"status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""),
|
||||
"raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""),
|
||||
"field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [],
|
||||
})
|
||||
return audit
|
||||
|
||||
|
||||
def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return report-safe audit metadata without argument values or prompts."""
|
||||
return [
|
||||
{
|
||||
"audit_id": item.get("audit_id"),
|
||||
"at": item.get("at"),
|
||||
"actor": item.get("actor"),
|
||||
"tool": item.get("tool"),
|
||||
"raw_arguments_hash": item.get("raw_arguments_hash"),
|
||||
"canonical_schema_valid": item.get("canonical_schema_valid"),
|
||||
"field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [],
|
||||
"state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {},
|
||||
"returned_tool": item.get("returned_tool"),
|
||||
"single_allowed_call": item.get("single_allowed_call"),
|
||||
}
|
||||
for item in audits
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
|
||||
def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Expose audit identifiers explicitly without copying provider payloads."""
|
||||
candidate_ids = sorted({
|
||||
str(item.get("candidate_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("candidate_id"), str) and item.get("candidate_id")
|
||||
})
|
||||
revision_ids = sorted({
|
||||
str(item.get("revision_id"))
|
||||
for item in ledger
|
||||
if isinstance(item.get("revision_id"), str) and item.get("revision_id")
|
||||
})
|
||||
return {"candidate_ids": candidate_ids, "revision_ids": revision_ids}
|
||||
|
||||
|
||||
def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for item in reversed(ledger):
|
||||
results = item.get("claim_results")
|
||||
if item.get("event") == "completed" and isinstance(results, list):
|
||||
return [value for value in results if isinstance(value, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]:
|
||||
"""Keep run linkage without publishing task or provider correlation values."""
|
||||
return [
|
||||
canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20]
|
||||
for item in invocations
|
||||
if isinstance(item, dict) and item.get("invocation_id")
|
||||
]
|
||||
|
||||
|
||||
def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]:
|
||||
"""Hash reviewable CAD evidence without copying source or prompt content."""
|
||||
allowed_exact = {
|
||||
"requirements-contract.json",
|
||||
"requirements.md",
|
||||
"completion-target.md",
|
||||
"completion-result.md",
|
||||
}
|
||||
allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-")
|
||||
files: list[dict[str, str]] = []
|
||||
if artifact_root.is_dir():
|
||||
for path in sorted(artifact_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(artifact_root).as_posix()
|
||||
if relative not in allowed_exact and not relative.startswith(allowed_prefixes):
|
||||
continue
|
||||
files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()})
|
||||
return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files}
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
try:
|
||||
scenarios = _fixture(arguments.suite, arguments.scenario)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
settings = get_settings()
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
if review_provider.id == author_provider.id and review_model.id == author_model.id:
|
||||
raise ValueError("Reviewer must differ from author")
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)}
|
||||
baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None
|
||||
if arguments.suite == "release" and baseline_path is None:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."}
|
||||
try:
|
||||
baseline = load_token_baseline(baseline_path) if baseline_path else None
|
||||
current_author_identity = author_request_identity(author_provider, author_model)
|
||||
runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json")
|
||||
except TokenBaselineError as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"}
|
||||
if baseline is not None:
|
||||
baseline_errors = validate_token_baseline_provenance(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline_errors:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
contracts = [
|
||||
{
|
||||
"atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"],
|
||||
"contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"],
|
||||
"registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"],
|
||||
}
|
||||
for atomic_id in services.workflow.runtime.supported_atomic_ids()
|
||||
]
|
||||
verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema())
|
||||
try:
|
||||
author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True)
|
||||
reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True)
|
||||
except Exception as error:
|
||||
return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]}
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
error, failure_layer = _capability_block_reason(author_capability, reviewer_capability)
|
||||
return {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": error,
|
||||
"failure_layer": failure_layer,
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for scenario in scenarios:
|
||||
for repetition in range(1, 4 if arguments.suite == "release" else 2):
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
# State transitions include local candidate recovery, so the
|
||||
# loop guard is deliberately independent from the externally
|
||||
# measured model-call budgets below.
|
||||
max_turns=max(8, int(scenario["max_total_calls"]) * 3),
|
||||
max_author_turns=int(scenario["max_author_turns"]),
|
||||
max_reviewer_turns=int(scenario["max_reviewer_turns"]),
|
||||
max_model_calls=int(scenario["max_total_calls"]),
|
||||
)
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)]
|
||||
if oracle_claims:
|
||||
services.workflow.requirements.register_evaluation_contract_oracle(
|
||||
task_id,
|
||||
oracle_claims,
|
||||
validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)],
|
||||
)
|
||||
services.workflow.create_task(task_id, str(scenario["request"]))
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(int(scenario["max_wall_seconds"])):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
usage = services.repository.usage_summary(task_id)
|
||||
ledger = services.repository.ledger_events(task_id)
|
||||
invocations = services.repository.invocation_records(task_id)
|
||||
tool_audits = services.repository.tool_audits(task_id)
|
||||
terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {})
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
artifact_manifest = _safe_artifact_manifest(artifact_root)
|
||||
finished = datetime.now(timezone.utc)
|
||||
checks = _run_checks(
|
||||
scenario,
|
||||
projection,
|
||||
usage,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
events,
|
||||
artifact_root,
|
||||
ledger,
|
||||
tool_audits,
|
||||
artifact_manifest,
|
||||
)
|
||||
success = all(checks.values())
|
||||
acceptance = _acceptance_coverage(
|
||||
scenario,
|
||||
services.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
services.repository.get_state(task_id).requirements_contract_path
|
||||
if services.repository.get_state(task_id) is not None
|
||||
else "",
|
||||
),
|
||||
)
|
||||
outcome = (
|
||||
"passed"
|
||||
if success
|
||||
else "validation_capability_gap"
|
||||
if not acceptance["complete"]
|
||||
and all(value for key, value in checks.items() if key != "acceptance_contract_coverage")
|
||||
else "failed"
|
||||
)
|
||||
failure_attribution = _failure_attribution(
|
||||
checks=checks,
|
||||
outcome=outcome,
|
||||
events=events,
|
||||
projection=projection,
|
||||
)
|
||||
results.append({
|
||||
"scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome,
|
||||
"started_at": started.isoformat(), "finished_at": finished.isoformat(),
|
||||
"duration_ms": round((finished - started).total_seconds() * 1000),
|
||||
"terminal": terminal, "projection": projection, "usage": usage, "checks": checks,
|
||||
"acceptance_coverage": acceptance,
|
||||
"failure_attribution": failure_attribution,
|
||||
"rejection_codes": _rejection_codes(events),
|
||||
"schema_rejection_count": len(_rejection_codes(events)),
|
||||
"retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"),
|
||||
"rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"),
|
||||
**_ledger_identifiers(ledger),
|
||||
"final_claim_results": _final_claim_results(ledger),
|
||||
"redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations),
|
||||
"tool_audits": _report_tool_audits(tool_audits),
|
||||
"artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"),
|
||||
"artifact_manifest": artifact_manifest,
|
||||
"ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events),
|
||||
})
|
||||
token_comparison = (
|
||||
compare_token_baseline(
|
||||
baseline,
|
||||
scenarios=scenarios,
|
||||
v3_results=results,
|
||||
author_identity=current_author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
if baseline is not None
|
||||
else {"schema_version": "cad.token-comparison.v1", "status": "not_required"}
|
||||
)
|
||||
token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed",
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"author_request_identity": current_author_identity,
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"author_capability": author_capability,
|
||||
"reviewer_capability": reviewer_capability,
|
||||
"structured_output_mode": {
|
||||
"author": str(author_capability.get("mode") or ""),
|
||||
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||
},
|
||||
"git_revision": _git_revision(),
|
||||
"protocol_version": "3.2",
|
||||
"runtime_profile_sha256": runtime_profile_hash,
|
||||
"operation_contracts": contracts,
|
||||
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||
"verifier_schema_hash": verifier_schema_hash,
|
||||
"token_comparison": token_comparison,
|
||||
"baseline_report": str(baseline_path) if baseline_path else "",
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = asyncio.run(_run(arguments, report_root))
|
||||
except KeyboardInterrupt:
|
||||
# Let an explicit operator interruption retain its normal CLI
|
||||
# semantics. An external kill cannot be reported reliably either.
|
||||
raise
|
||||
except BaseException as error:
|
||||
# A live evaluation may fail before it creates a task (for example
|
||||
# during conformance). Its report is still the release gate's audit
|
||||
# artifact, so an unexpected evaluator failure must not disappear.
|
||||
result = {
|
||||
"status": "LIVE_EVAL_BLOCKED",
|
||||
"error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}",
|
||||
"failure_layer": "configuration_or_network",
|
||||
}
|
||||
if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live:
|
||||
result["skip_reason"] = result.get("error", "live provider access is unavailable")
|
||||
result["status"] = "skipped"
|
||||
result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())})
|
||||
(report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False))
|
||||
if result["status"] == "passed":
|
||||
return 0
|
||||
if result["status"] == "skipped":
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Advance one persisted event for a task in an isolated live-evaluation root.
|
||||
|
||||
Long real-provider evaluations can outlive a command host's execution window.
|
||||
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
|
||||
closes the async generator after that event has been durably handled. Repeated
|
||||
invocations therefore resume the same task without re-running already
|
||||
persisted nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
report_root = arguments.report_root.resolve()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=report_root / "artifacts",
|
||||
conversation_root=report_root / "conversations",
|
||||
))
|
||||
before = services.repository.get_state(arguments.task_id)
|
||||
if before is None:
|
||||
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
|
||||
runner = services.workflow.run(
|
||||
task_id=arguments.task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
)
|
||||
try:
|
||||
name, payload = await anext(runner)
|
||||
except StopAsyncIteration:
|
||||
name, payload = "workflow_exhausted", {}
|
||||
finally:
|
||||
await runner.aclose()
|
||||
after = services.repository.get_state(arguments.task_id)
|
||||
return {
|
||||
"task_id": arguments.task_id,
|
||||
"event": {"name": name, "payload": payload},
|
||||
"before": {"phase": before.phase.value, "version": before.version},
|
||||
"after": {
|
||||
"phase": after.phase.value if after is not None else "",
|
||||
"version": after.version if after is not None else -1,
|
||||
"active_revision": after.active_revision if after is not None else "",
|
||||
"last_error": after.last_error.value if after is not None and after.last_error else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
try:
|
||||
result = asyncio.run(_advance(arguments))
|
||||
except BaseException as error:
|
||||
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Aggregate local results for the `cad.single-stage.v1` evaluation suite.
|
||||
|
||||
The evaluator accepts already-recorded task projections and never calls a
|
||||
model. This keeps rate reporting reproducible and separates it from live
|
||||
provider experiments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Return first-pass, final, compliance, and cost counters.
|
||||
|
||||
A record may contain ``attempts`` with ``schema_valid`` and
|
||||
``executable`` booleans, final lifecycle/revision information, requirement
|
||||
target states, usage records, and an elapsed duration in milliseconds.
|
||||
Missing fields are reported as zero rather than guessed.
|
||||
"""
|
||||
values = [item for item in records if isinstance(item, dict)]
|
||||
first_schema = first_executable = final_executable = 0
|
||||
targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")}
|
||||
calls = prompt_tokens = completion_tokens = duration_ms = 0
|
||||
for record in values:
|
||||
attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else []
|
||||
first = attempts[0] if attempts and isinstance(attempts[0], dict) else {}
|
||||
first_schema += int(bool(first.get("schema_valid")))
|
||||
first_executable += int(bool(first.get("executable")))
|
||||
final_executable += int(bool(record.get("published_revision") or record.get("active_revision")))
|
||||
for target in record.get("requirement_targets") or ():
|
||||
state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending"
|
||||
targets[state if state in targets else "pending"] += 1
|
||||
usage = record.get("usage") if isinstance(record.get("usage"), dict) else {}
|
||||
usage_records = usage.get("records") if isinstance(usage.get("records"), list) else []
|
||||
calls += len(usage_records)
|
||||
for item in usage_records:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
prompt_tokens += int(item.get("prompt_tokens") or 0)
|
||||
completion_tokens += int(item.get("completion_tokens") or 0)
|
||||
duration_ms += int(record.get("duration_ms") or 0)
|
||||
total = len(values)
|
||||
rate = lambda count: count / total if total else 0.0
|
||||
return {
|
||||
"schema_version": "cad.single-stage.eval-summary.v1",
|
||||
"tasks": total,
|
||||
"first_pass_schema_rate": rate(first_schema),
|
||||
"first_pass_executable_rate": rate(first_executable),
|
||||
"final_executable_rate": rate(final_executable),
|
||||
"requirement_targets": targets,
|
||||
"calls": calls,
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Provenance-checked token baseline comparison for the v3 release gate.
|
||||
|
||||
The target intentionally does not accept estimated tokens or a hand-written
|
||||
percentage. A v2 run must record the same fixed requests and author request
|
||||
configuration before it can be compared with a v3 release report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
from app.settings import ProviderConfig, ProviderModel
|
||||
|
||||
|
||||
BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1"
|
||||
BASELINE_PROTOCOL_VERSION = "2.0"
|
||||
MINIMUM_REPETITIONS = 3
|
||||
TOKEN_REDUCTION_TARGET = 0.30
|
||||
MEASUREMENTS = {"prompt_tokens", "context_chars"}
|
||||
|
||||
|
||||
class TokenBaselineError(ValueError):
|
||||
"""A baseline cannot prove the release token target."""
|
||||
|
||||
|
||||
def request_sha256(request: str) -> str:
|
||||
return sha256(request.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def profile_sha256(profile_path: Path) -> str:
|
||||
try:
|
||||
return sha256(profile_path.read_bytes()).hexdigest()
|
||||
except OSError as error:
|
||||
raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error
|
||||
|
||||
|
||||
def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]:
|
||||
"""Capture every author setting that can affect prompt-token comparison."""
|
||||
return {
|
||||
"provider": provider.id,
|
||||
"model": model.id,
|
||||
"api_style": provider.api_style,
|
||||
"reasoning_effort": provider.reasoning_effort,
|
||||
# Chat Completions is explicitly deterministic in StructuredModelGateway.
|
||||
# Responses models use their configured/provider-default sampling mode.
|
||||
"sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None},
|
||||
}
|
||||
|
||||
|
||||
def load_token_baseline(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise TokenBaselineError("Token baseline report must be a JSON object.")
|
||||
if payload.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.")
|
||||
if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.")
|
||||
if payload.get("measurement") not in MEASUREMENTS:
|
||||
raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_token_baseline_provenance(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> list[str]:
|
||||
"""Reject an incomparable baseline before any billable v3 API call."""
|
||||
errors: list[str] = []
|
||||
if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
||||
errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}")
|
||||
if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION:
|
||||
errors.append("protocol_version must equal 2.0")
|
||||
if baseline.get("author") != author_identity:
|
||||
errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release")
|
||||
if baseline.get("runtime_profile_sha256") != runtime_profile_hash:
|
||||
errors.append("runtime profile hash does not match the v3 release")
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
if measurement not in MEASUREMENTS:
|
||||
errors.append("measurement must be prompt_tokens or context_chars")
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
return [*errors, "scenarios must be an array"]
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list):
|
||||
errors.append(f"baseline {scenario_id} repetitions must be an array")
|
||||
continue
|
||||
by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
for repetition in expected_repetitions:
|
||||
run = by_repetition[repetition]
|
||||
if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
for field in ("completed", "final_review_passed", "deterministic_claims_pass"):
|
||||
if not isinstance(run.get(field), bool):
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}")
|
||||
return errors
|
||||
|
||||
|
||||
def compare_token_baseline(
|
||||
baseline: dict[str, Any],
|
||||
*,
|
||||
scenarios: list[dict[str, Any]],
|
||||
v3_results: list[dict[str, Any]],
|
||||
author_identity: dict[str, Any],
|
||||
runtime_profile_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a complete, fail-closed comparison for the release report."""
|
||||
errors = validate_token_baseline_provenance(
|
||||
baseline, scenarios=scenarios, author_identity=author_identity,
|
||||
runtime_profile_hash=runtime_profile_hash,
|
||||
)
|
||||
measurement = str(baseline.get("measurement") or "")
|
||||
|
||||
baseline_scenarios = baseline.get("scenarios")
|
||||
if not isinstance(baseline_scenarios, list):
|
||||
errors.append("scenarios must be an array")
|
||||
baseline_scenarios = []
|
||||
by_scenario = {
|
||||
str(item.get("scenario") or ""): item
|
||||
for item in baseline_scenarios
|
||||
if isinstance(item, dict) and item.get("scenario")
|
||||
}
|
||||
current_by_scenario: dict[str, list[dict[str, Any]]] = {}
|
||||
for result in v3_results:
|
||||
if isinstance(result, dict) and isinstance(result.get("scenario"), str):
|
||||
current_by_scenario.setdefault(result["scenario"], []).append(result)
|
||||
|
||||
baseline_tokens: list[int] = []
|
||||
current_tokens: list[int] = []
|
||||
baseline_completed: list[bool] = []
|
||||
current_completed: list[bool] = []
|
||||
baseline_final_review: list[bool] = []
|
||||
current_final_review: list[bool] = []
|
||||
baseline_deterministic: list[bool] = []
|
||||
current_deterministic: list[bool] = []
|
||||
per_scenario: list[dict[str, Any]] = []
|
||||
|
||||
for scenario in scenarios:
|
||||
scenario_id = str(scenario.get("id") or "")
|
||||
expected_request_hash = request_sha256(str(scenario.get("request") or ""))
|
||||
baseline_scenario = by_scenario.get(scenario_id)
|
||||
if not isinstance(baseline_scenario, dict):
|
||||
errors.append(f"missing baseline scenario {scenario_id}")
|
||||
continue
|
||||
if baseline_scenario.get("request_sha256") != expected_request_hash:
|
||||
errors.append(f"baseline request hash differs for {scenario_id}")
|
||||
continue
|
||||
repetitions = baseline_scenario.get("repetitions")
|
||||
if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
current = current_by_scenario.get(scenario_id, [])
|
||||
if len(current) < MINIMUM_REPETITIONS:
|
||||
errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions")
|
||||
continue
|
||||
baseline_by_repetition = {
|
||||
int(item.get("repetition")) : item
|
||||
for item in repetitions
|
||||
if isinstance(item, dict) and isinstance(item.get("repetition"), int)
|
||||
}
|
||||
current_by_repetition = {
|
||||
int(item.get("repetition")): item
|
||||
for item in current
|
||||
if isinstance(item.get("repetition"), int)
|
||||
}
|
||||
expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1))
|
||||
if not expected_repetitions.issubset(baseline_by_repetition):
|
||||
errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
if not expected_repetitions.issubset(current_by_repetition):
|
||||
errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}")
|
||||
continue
|
||||
|
||||
scenario_baseline_tokens: list[int] = []
|
||||
scenario_current_tokens: list[int] = []
|
||||
for repetition in sorted(expected_repetitions):
|
||||
baseline_run = baseline_by_repetition[repetition]
|
||||
author_tokens = _non_negative_int(baseline_run.get("author_metric"))
|
||||
plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric"))
|
||||
if author_tokens is None or plan_review_tokens is None:
|
||||
errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics")
|
||||
continue
|
||||
current_run = current_by_repetition[repetition]
|
||||
usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {}
|
||||
v3_author_tokens = _v3_author_metric(usage, measurement)
|
||||
if v3_author_tokens is None:
|
||||
errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage")
|
||||
continue
|
||||
scenario_baseline_tokens.append(author_tokens + plan_review_tokens)
|
||||
scenario_current_tokens.append(v3_author_tokens)
|
||||
baseline_completed.append(bool(baseline_run.get("completed")))
|
||||
current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED")
|
||||
baseline_final_review.append(bool(baseline_run.get("final_review_passed")))
|
||||
current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed")
|
||||
baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass")))
|
||||
checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {}
|
||||
current_deterministic.append(bool(checks.get("deterministic_claims_pass")))
|
||||
if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS:
|
||||
baseline_tokens.extend(scenario_baseline_tokens)
|
||||
current_tokens.extend(scenario_current_tokens)
|
||||
per_scenario.append({
|
||||
"scenario": scenario_id,
|
||||
"baseline_median_metric": median(scenario_baseline_tokens),
|
||||
"v3_median_author_metric": median(scenario_current_tokens),
|
||||
})
|
||||
|
||||
baseline_median = median(baseline_tokens) if baseline_tokens else None
|
||||
current_median = median(current_tokens) if current_tokens else None
|
||||
reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None
|
||||
baseline_valid = not errors
|
||||
checks = {
|
||||
"baseline_valid": baseline_valid,
|
||||
"median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET,
|
||||
"completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False,
|
||||
"final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False,
|
||||
"deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False,
|
||||
"plan_review_calls_removed": baseline_valid and all(
|
||||
not str(record.get("tool") or "").startswith("modeling_plan")
|
||||
for result in v3_results if isinstance(result, dict)
|
||||
for record in ((result.get("usage") or {}).get("records") or [])
|
||||
if isinstance(record, dict)
|
||||
),
|
||||
}
|
||||
return {
|
||||
"schema_version": "cad.token-comparison.v1",
|
||||
"baseline_protocol_version": baseline.get("protocol_version"),
|
||||
"measurement": measurement,
|
||||
"baseline_median_metric": baseline_median,
|
||||
"v3_median_author_metric": current_median,
|
||||
"median_metric_reduction": reduction,
|
||||
"target_median_metric_reduction": TOKEN_REDUCTION_TARGET,
|
||||
"baseline_completion_rate": _rate(baseline_completed),
|
||||
"v3_completion_rate": _rate(current_completed),
|
||||
"baseline_final_review_rate": _rate(baseline_final_review),
|
||||
"v3_final_review_rate": _rate(current_final_review),
|
||||
"baseline_deterministic_claim_rate": _rate(baseline_deterministic),
|
||||
"v3_deterministic_claim_rate": _rate(current_deterministic),
|
||||
"per_scenario": per_scenario,
|
||||
"errors": errors,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _non_negative_int(value: Any) -> int | None:
|
||||
return value if isinstance(value, int) and value >= 0 else None
|
||||
|
||||
|
||||
def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None:
|
||||
records = usage.get("records")
|
||||
if not isinstance(records, list):
|
||||
return None
|
||||
author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"]
|
||||
if not author_records:
|
||||
return None
|
||||
if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records):
|
||||
return None
|
||||
field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars"
|
||||
values = [
|
||||
_non_negative_int(item.get(field))
|
||||
for item in author_records
|
||||
]
|
||||
return sum(value for value in values if value is not None) if all(value is not None for value in values) else None
|
||||
|
||||
|
||||
def _rate(values: list[bool]) -> float | None:
|
||||
return sum(values) / len(values) if values else None
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Run real-provider CAD tasks with a usable-model success criterion.
|
||||
|
||||
This evaluator is intentionally narrower than the release gate in ``live.py``.
|
||||
It answers one operational question: can the current workflow reliably finish
|
||||
ordinary prompts with a downloadable/previewable checkpoint, even if some
|
||||
acceptance claims remain best-effort warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
DEFAULT_PROMPTS = (
|
||||
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
|
||||
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
|
||||
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
|
||||
)
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
|
||||
parser.add_argument("--max-wall-seconds", type=int, default=1200)
|
||||
parser.add_argument("--max-model-calls", type=int, default=60)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
|
||||
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
|
||||
for source in (terminal, projection):
|
||||
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
|
||||
if isinstance(code, str) and code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
|
||||
if not revision_id:
|
||||
return False
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
|
||||
|
||||
|
||||
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
|
||||
if str(projection.get("lifecycle") or "") == "waiting_retry":
|
||||
return "configuration_or_service"
|
||||
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
|
||||
return "code_or_flow"
|
||||
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
|
||||
return "configuration_or_service"
|
||||
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
|
||||
return "model_output_or_best_effort"
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
|
||||
if arguments.review_provider or arguments.review_model:
|
||||
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
|
||||
else:
|
||||
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
return {"status": "blocked", "error": str(error), "results": []}
|
||||
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
|
||||
services = compose_v3(isolated)
|
||||
services.workflow.config = replace(
|
||||
services.workflow.config,
|
||||
max_turns=max(8, arguments.max_model_calls * 3),
|
||||
max_model_calls=arguments.max_model_calls,
|
||||
)
|
||||
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
|
||||
results: list[dict[str, Any]] = []
|
||||
for index, prompt in enumerate(prompts, start=1):
|
||||
task_id = f"cad_{secrets.token_hex(6)}"
|
||||
services.workflow.create_task(task_id, prompt)
|
||||
events: list[dict[str, Any]] = []
|
||||
started = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with asyncio.timeout(arguments.max_wall_seconds):
|
||||
async for name, payload in services.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
events.append({"name": name, "payload": payload})
|
||||
except TimeoutError:
|
||||
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
|
||||
projection = services.repository.get_task_projection(task_id) or {}
|
||||
artifact_root = (isolated.task_root / task_id).resolve()
|
||||
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
|
||||
code = _result_code(events, projection)
|
||||
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
|
||||
results.append({
|
||||
"index": index,
|
||||
"task_id": task_id,
|
||||
"prompt": prompt,
|
||||
"success": usable,
|
||||
"failure_layer": "" if usable else _classify_failure(code, projection),
|
||||
"code": code,
|
||||
"phase": projection.get("phase"),
|
||||
"lifecycle": projection.get("lifecycle"),
|
||||
"active_revision": revision_id,
|
||||
"verification_status": projection.get("verification_status"),
|
||||
"artifact_root": str(artifact_root),
|
||||
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
|
||||
"usage": services.repository.usage_summary(task_id),
|
||||
"event_audit": [
|
||||
{
|
||||
"name": item.get("name"),
|
||||
"status": (item.get("payload") or {}).get("status"),
|
||||
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
|
||||
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
|
||||
"tool": (item.get("payload") or {}).get("tool"),
|
||||
}
|
||||
for item in events
|
||||
],
|
||||
})
|
||||
return {
|
||||
"status": "passed" if results and all(item["success"] for item in results) else "failed",
|
||||
"schema_version": "cad.usable-smoke.v1",
|
||||
"author": {"provider": author_provider.id, "model": author_model.id},
|
||||
"reviewer": {"provider": review_provider.id, "model": review_model.id},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = asyncio.run(_run(arguments, report_root))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except BaseException as error:
|
||||
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
|
||||
result.update({"report_root": str(report_root.resolve())})
|
||||
report_path = report_root / "usable-smoke-report.json"
|
||||
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
|
||||
return 0 if result["status"] == "passed" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -21,7 +21,7 @@ class InvocationRecord:
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CandidateStage:
|
||||
class StagingRevision:
|
||||
stage_id: str
|
||||
output_dir: str
|
||||
|
||||
@@ -59,38 +59,21 @@ class TaskRepository(Protocol):
|
||||
|
||||
class ArtifactStore(Protocol):
|
||||
def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ...
|
||||
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ...
|
||||
def read_source_index(self, task_id: str) -> dict[str, str]: ...
|
||||
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ...
|
||||
def read_source_requirements(self, task_id: str) -> str: ...
|
||||
def source_image_paths(self, task_id: str) -> list[str]: ...
|
||||
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ...
|
||||
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ...
|
||||
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ...
|
||||
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ...
|
||||
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ...
|
||||
def stage_output_dir(self, task_id: str, stage_id: str) -> str: ...
|
||||
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ...
|
||||
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ...
|
||||
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ...
|
||||
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ...
|
||||
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ...
|
||||
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class CadRuntime(Protocol):
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]: ...
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
|
||||
|
||||
|
||||
@@ -99,13 +82,5 @@ class ModelGateway(Protocol):
|
||||
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class ReviewGateway(Protocol):
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class VerifierExecutor(Protocol):
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
async def publish(self, event: dict[str, Any]) -> None: ...
|
||||
|
||||
+22
-123
@@ -7,13 +7,11 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.models.contracts import ChatRequest, ConversationPatch
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
from app.services.attachments import attachment_record, classify_upload, extract_document_text
|
||||
from app.services.image_processing import image_metadata
|
||||
from app.services.review_renderer import renderer_status
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
@@ -57,12 +55,6 @@ async def config() -> dict[str, Any]:
|
||||
for model in provider.models
|
||||
],
|
||||
})
|
||||
try:
|
||||
settings.resolve_review_model()
|
||||
renderer_ready, renderer_detail = renderer_status()
|
||||
review_error = "" if renderer_ready else renderer_detail
|
||||
except ValueError as error:
|
||||
review_error = str(error)
|
||||
return {
|
||||
"default_provider": settings.default_provider_id,
|
||||
"default_model": settings.llm_model,
|
||||
@@ -71,8 +63,6 @@ async def config() -> dict[str, Any]:
|
||||
"configured": settings.llm_configured,
|
||||
"library_samples": library.count(),
|
||||
"autonomous_generation": settings.autonomous_generation,
|
||||
"review_configured": not review_error,
|
||||
"review_error": review_error,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +113,7 @@ async def upload_conversation_attachment(
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
active_task_id = str(current.get("current_task_id") or "")
|
||||
active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None
|
||||
if str((active_task or {}).get("lifecycle") or "") == "running":
|
||||
raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state")
|
||||
kind = classify_upload(filename, file.content_type or "", len(data))
|
||||
@@ -145,101 +135,25 @@ async def upload_conversation_attachment(
|
||||
async def read_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
task = agent.v3.repository.get_task_projection(safe_id)
|
||||
task = agent.cad.repository.get_task_projection(safe_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
state = agent.v3.repository.get_state(safe_id)
|
||||
task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None
|
||||
task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None
|
||||
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary"))
|
||||
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"])
|
||||
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
|
||||
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
|
||||
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
|
||||
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
|
||||
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
|
||||
task["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
|
||||
if isinstance(task["feature_plan"], dict):
|
||||
plan = FeaturePlan.model_validate(task["feature_plan"])
|
||||
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
|
||||
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
|
||||
task["feature_nodes"] = [
|
||||
{
|
||||
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
|
||||
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
|
||||
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
|
||||
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
|
||||
**node_evidence.get(str(node.get("node_id") or ""), {}),
|
||||
}
|
||||
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
|
||||
]
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
|
||||
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
|
||||
task["usage"] = agent.v3.repository.usage_summary(safe_id)
|
||||
task["preview_revision"] = str(task.get("active_revision") or "")
|
||||
state = agent.cad.repository.get_state(safe_id)
|
||||
task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None
|
||||
task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None
|
||||
task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None
|
||||
task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None
|
||||
task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None
|
||||
task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json")
|
||||
result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None
|
||||
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None
|
||||
task["usage"] = agent.cad.repository.usage_summary(safe_id)
|
||||
return JSONResponse(task)
|
||||
|
||||
|
||||
def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]:
|
||||
"""Project frozen claims with the newest committed verification evidence.
|
||||
|
||||
This is API-only data derived from SQLite-backed ledger entries and the
|
||||
state-referenced frozen contract. It never parses Markdown or exposes a
|
||||
staged candidate as a task checkpoint.
|
||||
"""
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
|
||||
continue
|
||||
values = event.get("claim_results")
|
||||
if not isinstance(values, list):
|
||||
values = event.get("coverage")
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in values:
|
||||
if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest:
|
||||
latest[value["claim_id"]] = value
|
||||
result: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str):
|
||||
continue
|
||||
evidence = latest.get(claim["claim_id"], {})
|
||||
result.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"claim_id": claim["claim_id"],
|
||||
"claim_kind": str(claim.get("claim_kind") or ""),
|
||||
"deterministic": claim.get("verification_mode") == "deterministic",
|
||||
"status": str(evidence.get("status") or "pending"),
|
||||
"evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Expose server-evaluated checklist progress without parsing Markdown."""
|
||||
by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)}
|
||||
progress: list[dict[str, Any]] = []
|
||||
for requirement in (contract or {}).get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
|
||||
progress.append({
|
||||
"requirement_id": str(requirement.get("requirement_id") or ""),
|
||||
"statement": str(requirement.get("statement") or ""),
|
||||
"status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending",
|
||||
})
|
||||
return progress
|
||||
|
||||
|
||||
@app.delete("/v1/tasks/{task_id}")
|
||||
async def cancel_task(task_id: str) -> JSONResponse:
|
||||
try:
|
||||
@@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
|
||||
try:
|
||||
safe_id = safe_task_id(task_id)
|
||||
path = agent.v3.artifacts.artifact_path(safe_id, artifact_path)
|
||||
path = agent.cad.artifacts.artifact_path(safe_id, artifact_path)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
task = agent.v3.repository.get_task_projection(safe_id) or {}
|
||||
task = agent.cad.repository.get_task_projection(safe_id) or {}
|
||||
parts = artifact_path.split("/")
|
||||
revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else ""
|
||||
published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else ""
|
||||
published_revision = str(task.get("published_revision") or "")
|
||||
active_revision = str(task.get("active_revision") or task.get("current_revision") or "")
|
||||
if not revision_id:
|
||||
# The task directory also contains candidate staging, agent audit and
|
||||
# frozen-input files. None of those are a public artifact surface.
|
||||
if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path:
|
||||
return FileResponse(path, filename=path.name)
|
||||
raise HTTPException(status_code=403, detail="This task artifact is not public")
|
||||
|
||||
if revision_id == published_revision:
|
||||
@@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
f"revisions/{revision_id}/build-diagnostics.json",
|
||||
f"revisions/{revision_id}/renders/render-manifest.json",
|
||||
}
|
||||
if artifact_path not in published_paths:
|
||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
v32_checkpoint_paths = {
|
||||
f"revisions/{revision_id}/model.cdsl.json",
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
}
|
||||
verified_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
}
|
||||
# Each v3.2 node revision is immutable and manifest-published. Make those
|
||||
# checkpoints inspectable from the DAG while keeping every staging input,
|
||||
# rejected candidate and arbitrary task artifact private.
|
||||
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
|
||||
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
|
||||
|
||||
# A legacy running task may render its active checkpoint in the browser,
|
||||
# but cannot expose any other checkpoint artifact or failed-task preview.
|
||||
# A running task can render its active executable prefix, but its staging
|
||||
# inputs and diagnostics remain private until publication.
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""HTTP/SSE delivery adapter for the protocol v3 workflow."""
|
||||
|
||||
"""HTTP/SSE delivery adapter for the single-stage CAD protocol."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -10,8 +9,7 @@ import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.application.workflow import ModelIdentity
|
||||
from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability
|
||||
from app.cad_agent.composition import V3Services, compose_v3
|
||||
from app.cad_agent.composition import CadServices, compose_cad_services
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.models.contracts import ChatMessage
|
||||
@@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str:
|
||||
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
||||
|
||||
|
||||
def _response_language(text: str) -> str:
|
||||
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
|
||||
latin = sum(1 for char in text if char.isascii() and char.isalpha())
|
||||
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
|
||||
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"image_observation": "参考图片观察",
|
||||
"requirements_document_ready": "需求文档已冻结",
|
||||
"completion_target_ready": "完成目标已冻结",
|
||||
"requirements_compiled": "需求合同已编译",
|
||||
"modeling_plan_ready": "建模计划已冻结",
|
||||
"completion_result_ready": "完成结果已就绪",
|
||||
"model_protocol_check": "模型协议检查",
|
||||
"action_selection": "动作选择",
|
||||
"tool_call": "建模工具",
|
||||
"candidate_result": "候选构建",
|
||||
"candidate_review": "候选独立复核",
|
||||
"final_review": "最终独立复核",
|
||||
"requirements_ready": "需求分析",
|
||||
"authoring_cdsl_ready": "完整 CDSL",
|
||||
"cdsl_compiled": "CDSL 编译",
|
||||
"build_result": "CAD 构建",
|
||||
"repair_started": "CDSL 修复",
|
||||
"task_terminal": "生成任务",
|
||||
"state_changed": "任务状态",
|
||||
}
|
||||
|
||||
|
||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
lifecycle = str(payload.get("lifecycle") or "")
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running")
|
||||
status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running"
|
||||
return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status}
|
||||
|
||||
|
||||
def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||
part_id = str(part.get("id") or "")
|
||||
if part_id:
|
||||
for index, existing in enumerate(parts):
|
||||
if str(existing.get("id") or "") == part_id:
|
||||
parts[index] = part
|
||||
return
|
||||
parts.append(part)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Delivery boundary: no CAD state transitions or provider calls live here."""
|
||||
|
||||
def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None:
|
||||
self.settings = settings
|
||||
self.store = store # Conversation/attachment store, not v3 CAD state.
|
||||
self.library = library
|
||||
self.v3: V3Services = compose_v3(settings)
|
||||
self.settings, self.store, self.library = settings, store, library
|
||||
self.cad: CadServices = compose_cad_services(settings)
|
||||
self._autonomous_runs: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
async def resume_running_tasks(self) -> None:
|
||||
task_ids = self.v3.repository.running_task_ids()
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
except ValueError as error:
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
str(error),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
try:
|
||||
author_capability, reviewer_capability = await asyncio.gather(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
),
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
# Startup recovery must never bypass a production capability gate.
|
||||
# Connectivity failures are recoverable, but they may not leave a
|
||||
# task falsely marked running without a worker.
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE,
|
||||
str(error),
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_PROTOCOL_CHECK_PENDING,
|
||||
"Model protocol check is temporarily unavailable.",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
await self._park_startup_tasks(
|
||||
task_ids,
|
||||
ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED,
|
||||
"Selected author or reviewer did not pass the v3 conformance suite.",
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
if not self.settings.resume_running_tasks_on_startup:
|
||||
return
|
||||
for task_id in task_ids:
|
||||
if task_id in self._autonomous_runs:
|
||||
continue
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
|
||||
async def _park_startup_tasks(
|
||||
self,
|
||||
task_ids: list[str],
|
||||
error: ErrorCode,
|
||||
message: str,
|
||||
*,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
"""Make a failed startup gate durable instead of leaving phantom runs."""
|
||||
event_name = "waiting_retry" if retryable else "failed_model_capability"
|
||||
for task_id in task_ids:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None or task_id in self._autonomous_runs:
|
||||
continue
|
||||
try:
|
||||
next_state = transition(
|
||||
state,
|
||||
"waiting_retry" if retryable else "failed",
|
||||
error=error,
|
||||
)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
except ValueError:
|
||||
continue
|
||||
if self.v3.repository.compare_and_swap(next_state, events=[{
|
||||
"event": event_name,
|
||||
"code": error.value,
|
||||
"message": message[:1000],
|
||||
"startup_recovery": True,
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
return
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
for task_id in self.cad.repository.running_task_ids():
|
||||
if task_id not in self._autonomous_runs:
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}")
|
||||
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None:
|
||||
async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None:
|
||||
try:
|
||||
async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer):
|
||||
# A resumed task has no active SSE client, but its durable
|
||||
# state events must still leave the transactional outbox.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
finally:
|
||||
# Flush a final transition emitted immediately before the worker
|
||||
# exits, such as WAITING_RETRY or FAILED_INTERNAL.
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
|
||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED)
|
||||
if self.v3.repository.compare_and_swap(cancelled, events=[{
|
||||
"event": "task_cancelled",
|
||||
"from_phase": state.phase.value,
|
||||
"active_revision": state.active_revision,
|
||||
"checkpoint_preserved": bool(state.active_revision),
|
||||
}]):
|
||||
await self.v3.outbox.dispatch_pending(task_id=task_id)
|
||||
# Persist the terminal transition before interrupting the coroutine.
|
||||
# A concurrent worker will fail its optimistic CAS instead of reviving
|
||||
# the task after the caller has requested cancellation.
|
||||
self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}])
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
running = self._autonomous_runs.pop(task_id, None)
|
||||
if running and not running.done():
|
||||
running.cancel()
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def resume_retry(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Explicitly restart one durably parked infrastructure retry.
|
||||
|
||||
``WAITING_FOR_USER`` is deliberately excluded: it requires new user
|
||||
input, while this endpoint is only the controlled recovery route for
|
||||
bounded provider/render/storage failures.
|
||||
"""
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
if state.phase.value != "WAITING_RETRY":
|
||||
raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint")
|
||||
running = self._autonomous_runs.get(task_id)
|
||||
if running is not None and not running.done():
|
||||
if state.phase != TaskPhase.FAILED or state.retry_from_phase is None:
|
||||
raise ValueError("Only a retryable failed CAD task can be resumed")
|
||||
if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done():
|
||||
raise ValueError("The CAD task is already running")
|
||||
author_provider, author_model = self.settings.resolve_model(None, None)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
author_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=author_provider.id,
|
||||
model_id=author_model.id,
|
||||
role="author",
|
||||
)
|
||||
reviewer_capability = await verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=review_provider.id,
|
||||
model_id=review_model.id,
|
||||
role="reviewer",
|
||||
)
|
||||
if not author_capability.get("supported") or not reviewer_capability.get("supported"):
|
||||
raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a recoverable retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(
|
||||
self._consume_discarding(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
),
|
||||
name=f"resume-autonomous-cad-v3-{task_id}",
|
||||
)
|
||||
return self.v3.repository.get_task_projection(task_id)
|
||||
provider, model = self.settings.resolve_model(None, None)
|
||||
if not self.cad.workflow.resume(task_id):
|
||||
raise ValueError("The CAD task no longer has a retry checkpoint")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}")
|
||||
return self.cad.repository.get_task_projection(task_id)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
conversation_id: str | None,
|
||||
selected_task_id: str | None,
|
||||
provider_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
viewer_context: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[bytes]:
|
||||
async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]:
|
||||
del viewer_context
|
||||
latest_user = next((message for message in reversed(messages) if message.role == "user"), None)
|
||||
if latest_user is None or not text_from_message(latest_user):
|
||||
latest = next((item for item in reversed(messages) if item.role == "user"), None)
|
||||
if latest is None or not text_from_message(latest):
|
||||
yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."})
|
||||
yield event("done", {})
|
||||
return
|
||||
request = text_from_message(latest_user)
|
||||
request = text_from_message(latest)
|
||||
conversation = self.store.ensure_conversation(conversation_id)
|
||||
selected = str(selected_task_id or conversation.get("current_task_id") or "")
|
||||
current = self.v3.repository.get_task_projection(selected) if selected else None
|
||||
resumed_task_id = ""
|
||||
if current and str(current.get("lifecycle") or "") == "waiting_for_user":
|
||||
state = self.v3.repository.get_state(selected)
|
||||
if state is not None:
|
||||
terminal = self.v3.workflow.waiting_for_user_terminal(selected, state)
|
||||
fields = [
|
||||
{"path": "/requirements/clarification", "message": str(question)}
|
||||
for question in terminal.get("questions") or ()
|
||||
if str(question).strip()
|
||||
]
|
||||
fields.extend(
|
||||
{"path": "/requirements", "message": str(issue)}
|
||||
for issue in terminal.get("issues") or ()
|
||||
if str(issue).strip()
|
||||
)
|
||||
if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id):
|
||||
yield event("cad_error", {
|
||||
"stage": "request",
|
||||
"message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]),
|
||||
"fieldErrors": fields,
|
||||
"taskId": selected,
|
||||
"blockerType": terminal.get("blockerType"),
|
||||
"userActionRequired": terminal.get("userActionRequired"),
|
||||
})
|
||||
current = self.cad.repository.get_task_projection(selected) if selected else None
|
||||
resumed = False
|
||||
if current and current.get("lifecycle") == "waiting_for_user":
|
||||
resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id)
|
||||
if not resumed:
|
||||
yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."})
|
||||
yield event("done", {})
|
||||
return
|
||||
resumed_task_id = selected
|
||||
if current and str(current.get("lifecycle") or "") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"})
|
||||
if current and current.get("lifecycle") == "running":
|
||||
yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"})
|
||||
yield event("done", {})
|
||||
return
|
||||
# A terminal task is immutable; follow-up text creates a new task.
|
||||
task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed_task_id:
|
||||
task_id = selected if resumed else f"cad_{secrets.token_hex(6)}"
|
||||
if not resumed:
|
||||
try:
|
||||
source_blocks, image_inputs = self._task_inputs(conversation, request)
|
||||
self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs)
|
||||
except ValueError as error:
|
||||
yield event("cad_error", {"stage": "request", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id)
|
||||
yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."})
|
||||
|
||||
self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id)
|
||||
yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."}))
|
||||
try:
|
||||
author_provider, author_model = self.settings.resolve_model(provider_id, model_id)
|
||||
review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model)
|
||||
provider, model = self.settings.resolve_model(provider_id, model_id)
|
||||
except ValueError as error:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None:
|
||||
state = self.cad.repository.get_state(task_id)
|
||||
if state:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_configuration_invalid",
|
||||
"message": str(error)[:1000],
|
||||
"issues": [str(error)[:1000]],
|
||||
}])
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False}
|
||||
yield event("task_terminal", terminal)
|
||||
self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}])
|
||||
yield event("cad_error", {"stage": "configuration", "message": str(error)})
|
||||
yield event("done", {})
|
||||
return
|
||||
|
||||
author = ModelIdentity(provider.id, model.id)
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||
parts: list[dict[str, Any]] = []
|
||||
sequence = 0
|
||||
|
||||
async def consume() -> None:
|
||||
nonlocal sequence
|
||||
|
||||
async def dispatch_state_events() -> None:
|
||||
"""Publish state notifications only through the outbox.
|
||||
|
||||
The dispatcher preserves at-least-once semantics. The SSE
|
||||
event ID derives from the durable outbox row, so reconnecting
|
||||
clients can safely de-duplicate a delivery replay.
|
||||
"""
|
||||
nonlocal sequence
|
||||
for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id):
|
||||
sequence += 1
|
||||
event_name = str(outbox_event.get("event") or "state_changed")
|
||||
payload = {
|
||||
"taskId": task_id,
|
||||
"eventId": f"outbox_{outbox_event['event_id']}",
|
||||
"sequence": sequence,
|
||||
"timestamp": now_iso(),
|
||||
"outboxEvent": event_name,
|
||||
"message": event_name.replace("_", " "),
|
||||
}
|
||||
await queue.put(("progress", _visible_progress("state_changed", payload)))
|
||||
|
||||
try:
|
||||
capability_terminal = await self._ensure_task_capabilities(
|
||||
task_id,
|
||||
ModelIdentity(author_provider.id, author_model.id),
|
||||
ModelIdentity(review_provider.id, review_model.id),
|
||||
queue,
|
||||
)
|
||||
if capability_terminal is not None:
|
||||
sequence += 1
|
||||
capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)})
|
||||
await queue.put(("task_terminal", capability_terminal))
|
||||
return
|
||||
async for name, payload in self.v3.workflow.run(
|
||||
task_id=task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
):
|
||||
sequence += 1
|
||||
decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||
if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed":
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": {
|
||||
"stage": "generation",
|
||||
"message": str(decorated.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(decorated.get("tool") or ""),
|
||||
"fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [],
|
||||
}})
|
||||
async for name, payload in self.cad.workflow.run(task_id=task_id, author=author):
|
||||
decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()}
|
||||
parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)})
|
||||
await queue.put((name, decorated))
|
||||
await dispatch_state_events()
|
||||
except Exception as error:
|
||||
sequence += 1
|
||||
terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False}
|
||||
_upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)})
|
||||
_upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}})
|
||||
await queue.put(("task_terminal", terminal))
|
||||
await self.cad.outbox.dispatch_pending(task_id=task_id)
|
||||
if name == "task_terminal" and decorated.get("lifecycle") == "completed":
|
||||
projection = self.cad.repository.get_task_projection(task_id) or {}
|
||||
result = self._result_payload(task_id, projection)
|
||||
if result is not None:
|
||||
await queue.put(("cad_result", result))
|
||||
finally:
|
||||
self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id)
|
||||
self._autonomous_runs.pop(task_id, None)
|
||||
await queue.put(None)
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}")
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}")
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
|
||||
continue
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
name, payload = item
|
||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||
yield event("cad_error", {
|
||||
"stage": "generation",
|
||||
"message": str(payload.get("message") or "CAD autonomous generation failed."),
|
||||
"tool": str(payload.get("tool") or ""),
|
||||
"fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [],
|
||||
})
|
||||
if name == "task_terminal" and payload.get("lifecycle") == "failed":
|
||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")})
|
||||
yield event(name, payload)
|
||||
yield event("done", {})
|
||||
|
||||
async def _ensure_task_capabilities(
|
||||
self,
|
||||
task_id: str,
|
||||
author: ModelIdentity,
|
||||
reviewer: ModelIdentity,
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None],
|
||||
) -> dict[str, Any] | None:
|
||||
roles = (("author", author), ("reviewer", reviewer))
|
||||
cached = {
|
||||
role: cached_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in roles
|
||||
@staticmethod
|
||||
def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None:
|
||||
revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "")
|
||||
revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else []
|
||||
revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
required = ("cdsl_path", "step_path", "glb_path", "report_path")
|
||||
if not all(isinstance(revision.get(path), str) and revision[path] for path in required):
|
||||
return None
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"revisionId": revision_id,
|
||||
"cdslPath": revision["cdsl_path"],
|
||||
"stepPath": revision["step_path"],
|
||||
"glbPath": revision["glb_path"],
|
||||
"reportPath": revision["report_path"],
|
||||
"summary": str(revision.get("summary") or "CDSL CAD model"),
|
||||
"referenceIds": list(revision.get("reference_ids") or []),
|
||||
"engine": str(revision.get("engine") or "cdsl_only"),
|
||||
"lifecycle": str(projection.get("lifecycle") or "completed"),
|
||||
}
|
||||
unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
missing = [(role, model) for role, model in roles if cached[role] is None]
|
||||
if not missing:
|
||||
return None
|
||||
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is None:
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False}
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
|
||||
if not self.v3.repository.compare_and_swap(waiting, events=[{
|
||||
"event": "model_protocol_check_pending",
|
||||
"code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value,
|
||||
"message": "模型协议检查中,完成后将自动继续。",
|
||||
}]):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"}))
|
||||
try:
|
||||
results = await asyncio.gather(*(
|
||||
verify_model_capability(
|
||||
self.v3.repository,
|
||||
self.v3.workflow.runtime,
|
||||
self.v3.models,
|
||||
provider_id=model.provider_id,
|
||||
model_id=model.model_id,
|
||||
role=role,
|
||||
)
|
||||
for role, model in missing
|
||||
))
|
||||
except Exception as error:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False}
|
||||
unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")]
|
||||
if unavailable:
|
||||
return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False}
|
||||
unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")]
|
||||
if unsupported:
|
||||
return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}")
|
||||
if not self.v3.workflow.resume(task_id):
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False}
|
||||
await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"}))
|
||||
return None
|
||||
|
||||
def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]:
|
||||
state = self.v3.repository.get_state(task_id)
|
||||
if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}:
|
||||
failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED)
|
||||
self.v3.repository.compare_and_swap(failed, events=[{
|
||||
"event": "model_protocol_unsupported",
|
||||
"message": message,
|
||||
"issues": [message],
|
||||
}])
|
||||
return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False}
|
||||
|
||||
def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Freeze message paragraphs and attachment blocks before task creation."""
|
||||
blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()]
|
||||
image_inputs: list[dict[str, str]] = []
|
||||
conversation_id = str(conversation.get("conversation_id") or "")
|
||||
@@ -509,51 +201,24 @@ class AgentService:
|
||||
continue
|
||||
if str(attachment.get("conversation_id") or "") != conversation_id:
|
||||
raise ValueError("Attachment does not belong to this conversation")
|
||||
attachment_id = str(attachment.get("id") or "")
|
||||
relative_path = str(attachment.get("path") or "")
|
||||
expected_digest = str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest):
|
||||
attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "")
|
||||
if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError("Attachment metadata is incomplete")
|
||||
binary_path = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary_path.is_file():
|
||||
raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}")
|
||||
if sha256(binary_path.read_bytes()).hexdigest() != expected_digest:
|
||||
raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}")
|
||||
binary = self.store.conversation_attachment_path(conversation_id, relative_path)
|
||||
if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest:
|
||||
raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}")
|
||||
kind = str(attachment.get("kind") or "")
|
||||
if kind == "document":
|
||||
extracted_path = str(attachment.get("extracted_path") or "")
|
||||
if not extracted_path:
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, extracted_path)
|
||||
text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or ""))
|
||||
if not text_path.is_file():
|
||||
raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}")
|
||||
raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}")
|
||||
text = text_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
elif kind == "image":
|
||||
# The binary is integrity-checked above. Do not claim that a
|
||||
# text-only author has interpreted visual content; the source
|
||||
# block is still a stable, reviewable attachment reference.
|
||||
text = (
|
||||
f"Visual attachment {attachment.get('name') or attachment_id} "
|
||||
f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). "
|
||||
"It is a visual reference and requires explicit visual verification."
|
||||
)
|
||||
image_inputs.append({
|
||||
"path": str(binary_path),
|
||||
"mime": str(attachment.get("mime") or "image/*"),
|
||||
"sha256": expected_digest,
|
||||
})
|
||||
text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})."
|
||||
image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest})
|
||||
else:
|
||||
raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}")
|
||||
if not text:
|
||||
raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}")
|
||||
blocks.append({
|
||||
"text": text,
|
||||
"attachment": {
|
||||
"attachment_id": attachment_id,
|
||||
"name": str(attachment.get("name") or attachment_id),
|
||||
"kind": kind,
|
||||
"mime": str(attachment.get("mime") or "application/octet-stream"),
|
||||
"sha256": expected_digest,
|
||||
},
|
||||
})
|
||||
blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}})
|
||||
return blocks, image_inputs
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Derived-metrics backfill for CDSL documents (training-grade, server-owned).
|
||||
|
||||
The metrics describe the *reconstructed geometry* of a document and are
|
||||
written exclusively by the server after a successful rebuild. Models never
|
||||
author them; the fields are excluded from validation ordering (backfilled
|
||||
after validation) so they can never influence acceptance.
|
||||
|
||||
Counting conventions:
|
||||
- ``hole_count`` : inner cylindrical walls on the final body (cylinder faces
|
||||
whose ``cylinder_role`` is ``inner``). Countersink/counterbore bores add
|
||||
their own walls; no coaxial clustering is applied in v1.
|
||||
- ``fillet_count``: torus faces (rolling-ball blends) on the final body.
|
||||
- ``face_type_distribution``: final-body faces by ``surface_type``.
|
||||
- ``per_feature`` : prefix re-execution sampling. Prefix ``i`` re-runs the
|
||||
first ``i`` features; sampling stops at the first non-executable prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.services.engine_service import topology_snapshot
|
||||
|
||||
_METRIC_SNAPSHOT_KEYS = (
|
||||
"volume_mm3", "surface_area_mm2", "bbox_mm", "solid_count",
|
||||
"face_count", "edge_count", "vertex_count", "hole_count",
|
||||
"fillet_count", "face_type_distribution",
|
||||
)
|
||||
_DELTA_KEYS = ("volume_mm3", "surface_area_mm2", "face_count", "hole_count")
|
||||
|
||||
#: Prefix sampling above this feature count degrades to final-only metrics,
|
||||
#: keeping the O(n^2) re-execution cost bounded on very large documents.
|
||||
MAX_PER_FEATURE_SAMPLES = 64
|
||||
|
||||
|
||||
def _face_stats(topology: dict[str, Any]) -> dict[str, Any]:
|
||||
faces = [
|
||||
record for record in (topology.get("records") or [])
|
||||
if isinstance(record, dict) and record.get("kind") == "face"
|
||||
]
|
||||
distribution: Counter[str] = Counter()
|
||||
hole_walls = 0
|
||||
torus = 0
|
||||
for face in faces:
|
||||
geometry = face.get("geometry") or {}
|
||||
surface_type = str(geometry.get("surface_type") or "unknown")
|
||||
distribution[surface_type] += 1
|
||||
if surface_type == "cylinder" and geometry.get("cylinder_role") == "inner":
|
||||
hole_walls += 1
|
||||
if surface_type == "torus":
|
||||
torus += 1
|
||||
return {
|
||||
"face_type_distribution": dict(sorted(distribution.items())),
|
||||
"hole_count": hole_walls,
|
||||
"fillet_count": torus,
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_from_engine_result(engine_result: dict[str, Any], topology: dict[str, Any]) -> dict[str, Any]:
|
||||
records = [r for r in (topology.get("records") or []) if isinstance(r, dict)]
|
||||
faces = [r for r in records if r.get("kind") == "face"]
|
||||
area = sum(float((f.get("geometry") or {}).get("area_mm2") or 0.0) for f in faces)
|
||||
snapshot = {
|
||||
"volume_mm3": float(engine_result.get("volume_mm3") or 0.0),
|
||||
"surface_area_mm2": area or float(engine_result.get("surface_area_mm2") or 0.0),
|
||||
"bbox_mm": [float(value) for value in (engine_result.get("bbox_mm") or {}).get("min", [])]
|
||||
+ [float(value) for value in (engine_result.get("bbox_mm") or {}).get("max", [])],
|
||||
"solid_count": int(engine_result.get("solid_count") or 0),
|
||||
"face_count": len(faces),
|
||||
"edge_count": sum(1 for r in records if r.get("kind") == "edge"),
|
||||
"vertex_count": sum(1 for r in records if r.get("kind") == "vertex"),
|
||||
}
|
||||
snapshot.update(_face_stats(topology))
|
||||
return {key: snapshot[key] for key in _METRIC_SNAPSHOT_KEYS}
|
||||
|
||||
|
||||
def _delta(after: dict[str, Any], before: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"volume_mm3": float(after["volume_mm3"]) - float(before["volume_mm3"]),
|
||||
"surface_area_mm2": float(after["surface_area_mm2"]) - float(before["surface_area_mm2"]),
|
||||
"face_count": int(after["face_count"]) - int(before["face_count"]),
|
||||
"hole_count": int(after["hole_count"]) - int(before["hole_count"]),
|
||||
}
|
||||
|
||||
|
||||
def derive_metrics(
|
||||
cdsl: dict[str, Any],
|
||||
engine_result: dict[str, Any],
|
||||
topology: dict[str, Any],
|
||||
*,
|
||||
engine_build: str = "",
|
||||
prefix_runner: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the ``meta.derived_metrics`` payload for a successfully rebuilt document.
|
||||
|
||||
``prefix_runner`` re-executes a prefix of the document's features and
|
||||
returns the same shape as ``engine_result``; when omitted, ``per_feature``
|
||||
degrades to status entries without geometric snapshots.
|
||||
"""
|
||||
features = cdsl.get("features") or []
|
||||
final = _snapshot_from_engine_result(engine_result, topology)
|
||||
zero = {
|
||||
"volume_mm3": 0.0, "surface_area_mm2": 0.0,
|
||||
"face_count": 0, "hole_count": 0,
|
||||
}
|
||||
|
||||
per_feature: list[dict[str, Any]] = []
|
||||
if prefix_runner is not None and 1 <= len(features) <= MAX_PER_FEATURE_SAMPLES:
|
||||
previous = zero
|
||||
for index in range(1, len(features) + 1):
|
||||
prefix_doc = deepcopy(cdsl)
|
||||
prefix_doc["features"] = deepcopy(features[:index])
|
||||
try:
|
||||
prefix_result = prefix_runner(prefix_doc)
|
||||
topology_prefix = topology_snapshot(prefix_result)
|
||||
after = _snapshot_from_engine_result(prefix_result, topology_prefix)
|
||||
status = str(next(
|
||||
(fr.get("status") for fr in prefix_result.get("feature_results") or []
|
||||
if fr.get("feature_id") == features[index - 1].get("id")),
|
||||
"executed",
|
||||
))
|
||||
except Exception:
|
||||
break # prefix chain broken; keep samples collected so far
|
||||
per_feature.append({
|
||||
"feature_id": str(features[index - 1].get("id") or ""),
|
||||
"atomic_id": str(features[index - 1].get("atomic_id") or ""),
|
||||
"status": status,
|
||||
"after": after,
|
||||
"delta": _delta(after, previous),
|
||||
})
|
||||
previous = after
|
||||
|
||||
histogram = Counter(str(feature.get("atomic_id") or "") for feature in features)
|
||||
metrics: dict[str, Any] = {
|
||||
"engine": str(engine_result.get("engine") or ""),
|
||||
"final": final,
|
||||
"per_feature": per_feature,
|
||||
"atomic_histogram": dict(sorted(histogram.items())),
|
||||
}
|
||||
if engine_build:
|
||||
metrics["engine_build"] = engine_build
|
||||
return metrics
|
||||
|
||||
|
||||
def make_prefix_runner(run_cdsl_only: Callable[[dict[str, Any], Path], dict[str, Any]]):
|
||||
"""Wrap an engine ``run_cdsl_only`` entry point for prefix sampling."""
|
||||
def runner(prefix_doc: dict[str, Any]) -> dict[str, Any]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
return run_cdsl_only(prefix_doc, Path(tmp) / "prefix.step")
|
||||
return runner
|
||||
@@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict):
|
||||
raise RuntimeError("The local engine schema has no v3 operation contract registry")
|
||||
raise RuntimeError("The local engine schema has no operation contract registry")
|
||||
return schema
|
||||
|
||||
|
||||
@@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate the static Runtime CDSL contract without resolving topology."""
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
if cdsl.get("schema") != "cad.cdsl.llm.v1":
|
||||
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
|
||||
raise ValueError("Unsupported CDSL schema")
|
||||
_validate_cdsl_json_schema(cdsl, engine)
|
||||
part_id = str(cdsl.get("part_id") or "")
|
||||
@@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
sketches = cdsl.get("geometry", {}).get("sketches")
|
||||
if not isinstance(features, list) or not features or not isinstance(sketches, list):
|
||||
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
|
||||
if cdsl.get("schema") == "cad.runtime.v1":
|
||||
bodies = cdsl.get("bodies")
|
||||
if not isinstance(bodies, list) or not bodies:
|
||||
raise ValueError("Runtime CDSL requires server-assigned bodies")
|
||||
body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)]
|
||||
body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)]
|
||||
if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)):
|
||||
raise ValueError("Runtime CDSL bodies must have unique server IDs and local names")
|
||||
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
|
||||
semantic_contract = _engine_schema(engine)
|
||||
try:
|
||||
@@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError("Polygon profiles require vertices")
|
||||
elif profile_type not in engine.SHAPE_GENERATORS:
|
||||
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
"""Validate static CDSL and the current executable topology semantics."""
|
||||
validate_cdsl_shape(cdsl, engine)
|
||||
try:
|
||||
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
|
||||
except Exception as error:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Deterministic, CPU-only CAD technical renders for visual review.
|
||||
"""Deterministic, CPU-only CAD technical render bundles.
|
||||
|
||||
OpenCascade computes exact visible/hidden edges from the revision STEP file.
|
||||
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence
|
||||
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
|
||||
consistent on macOS, Linux, and Windows workers.
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69)
|
||||
HIDDEN_EDGE_RGB = (142, 157, 170)
|
||||
|
||||
|
||||
class ReviewRenderError(RuntimeError):
|
||||
class RenderBundleError(RuntimeError):
|
||||
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]:
|
||||
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
|
||||
try:
|
||||
_render_modules()
|
||||
except ReviewRenderError as error:
|
||||
except RenderBundleError as error:
|
||||
return False, str(error)
|
||||
return True, ""
|
||||
|
||||
@@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]:
|
||||
pillow_draw = importlib.import_module("PIL.ImageDraw")
|
||||
import_step = importlib.import_module("build123d").import_step
|
||||
except (ImportError, AttributeError) as error:
|
||||
raise ReviewRenderError(
|
||||
raise RenderBundleError(
|
||||
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
|
||||
) from error
|
||||
return pillow_image, pillow_draw, import_step
|
||||
@@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]:
|
||||
box = shape.bounding_box()
|
||||
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
|
||||
if not all(math.isfinite(value) for value in bounds):
|
||||
raise ReviewRenderError("STEP review source has invalid bounds")
|
||||
raise RenderBundleError("STEP render source has invalid bounds")
|
||||
return bounds
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
|
||||
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
|
||||
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
|
||||
if not points:
|
||||
raise ReviewRenderError("Hidden-line projection produced no drawable edges")
|
||||
raise RenderBundleError("Hidden-line projection produced no drawable edges")
|
||||
xs, ys = zip(*points)
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
@@ -242,7 +242,7 @@ def _render_view(
|
||||
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
|
||||
)
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
||||
visible_edges, hidden_edges = list(visible), list(hidden)
|
||||
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
|
||||
rendered = _rasterize(
|
||||
@@ -254,12 +254,12 @@ def _render_view(
|
||||
intentional_crop=target_extent > 0,
|
||||
)
|
||||
if not rendered["diagnostics"]["valid"]:
|
||||
raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
||||
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
|
||||
|
||||
|
||||
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
|
||||
"""Create compact whole-model evidence for routine reviewer calls."""
|
||||
"""Create compact whole-model images for published CAD artifacts."""
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
|
||||
if not canonical:
|
||||
@@ -283,27 +283,27 @@ def render_checkpoint(
|
||||
*,
|
||||
step_path: Path,
|
||||
output_dir: Path,
|
||||
review_targets: list[dict[str, Any]] | None = None,
|
||||
detail_targets: list[dict[str, Any]] | None = None,
|
||||
include_canonical: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Render STEP geometry into stable canonical and bounded node-detail views."""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP review source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
shape = import_step(str(step_path))
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error
|
||||
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
|
||||
bounds = _shape_bounds(shape)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
jobs: list[tuple[str, dict[str, Any] | None]] = []
|
||||
if include_canonical:
|
||||
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3]))
|
||||
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
|
||||
views = [
|
||||
_render_view(
|
||||
shape=shape,
|
||||
@@ -317,14 +317,14 @@ def render_checkpoint(
|
||||
]
|
||||
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
|
||||
if include_canonical and canonical != set(CANONICAL_VIEWS):
|
||||
raise ReviewRenderError("Python review renderer did not produce every canonical view")
|
||||
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
|
||||
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
|
||||
manifest = {
|
||||
"schema_version": "cad.render-manifest.v2",
|
||||
"renderer": "python-occ-hlr-pillow",
|
||||
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
|
||||
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
|
||||
"review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
||||
"contact_sheet_path": contact_sheet_path,
|
||||
"views": views,
|
||||
}
|
||||
@@ -343,22 +343,22 @@ def render_section(
|
||||
"""Create an actual OpenCascade section drawing, not a clipped viewport.
|
||||
|
||||
It intentionally uses the same deterministic Pillow raster path as the
|
||||
seven canonical review views. The output contains compact contour evidence
|
||||
suitable for a multimodal author without sending a STEP file or full B-rep.
|
||||
seven canonical render views. The output contains compact contour evidence
|
||||
suitable for inspection without sending a STEP file or full B-rep.
|
||||
"""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
raise RenderBundleError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
|
||||
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
|
||||
origin = _number_list(origin_mm, size=3)
|
||||
direction = _number_list(normal, size=3)
|
||||
if origin is None or direction is None:
|
||||
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
|
||||
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
|
||||
length = math.sqrt(sum(value * value for value in direction))
|
||||
if length <= 1e-9:
|
||||
raise ReviewRenderError("Section normal must not be zero")
|
||||
raise RenderBundleError("Section normal must not be zero")
|
||||
normal_unit = [value / length for value in direction]
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
@@ -372,9 +372,9 @@ def render_section(
|
||||
section = b3d.section(shape, section_by=plane)
|
||||
edges = list(section.edges())
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
|
||||
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
|
||||
if not edges:
|
||||
raise ReviewRenderError("Section plane does not intersect the model")
|
||||
raise RenderBundleError("Section plane does not intersect the model")
|
||||
|
||||
# Choose a deterministic right-handed in-plane frame. Projecting exact
|
||||
# OCC section edges into this frame preserves holes and internal contours.
|
||||
@@ -406,7 +406,7 @@ def render_section(
|
||||
if len(line) >= 2:
|
||||
projected.append(line)
|
||||
if not projected:
|
||||
raise ReviewRenderError("Section operation produced no drawable contours")
|
||||
raise RenderBundleError("Section operation produced no drawable contours")
|
||||
xs = [point[0] for line in projected for point in line]
|
||||
ys = [point[1] for line in projected for point in line]
|
||||
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Conversation and attachment storage for the v3 delivery boundary.
|
||||
"""Conversation and attachment storage for the CAD delivery boundary.
|
||||
|
||||
CAD task state deliberately does not live here. Protocol v3 owns mutable task
|
||||
CAD task state deliberately does not live here. The Authoring protocol owns mutable task
|
||||
state in ``SqliteTaskRepository`` and immutable task artifacts in
|
||||
``FileArtifactStore``.
|
||||
"""
|
||||
|
||||
@@ -65,13 +65,6 @@ class Settings:
|
||||
llm_timeout_s: float
|
||||
default_provider_id: str
|
||||
providers: tuple[ProviderConfig, ...]
|
||||
review_provider_id: str = ""
|
||||
review_model_id: str = ""
|
||||
agent_tool_calls_per_cycle: int = 12
|
||||
agent_consecutive_no_progress_limit: int = 6
|
||||
agent_format_error_repeat_limit: int = 3
|
||||
agent_context_char_limit: int = 14000
|
||||
agent_render_cache: bool = True
|
||||
autonomous_generation: bool = True
|
||||
resume_running_tasks_on_startup: bool = True
|
||||
|
||||
@@ -100,29 +93,6 @@ class Settings:
|
||||
raise ValueError("The selected model is not enabled for this provider")
|
||||
return provider, model
|
||||
|
||||
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Return the independently configured visual reviewer, never an author fallback."""
|
||||
provider_id = self.review_provider_id
|
||||
if not provider_id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
|
||||
provider = self.provider_for(provider_id)
|
||||
if provider is None:
|
||||
raise ValueError("The configured visual review provider is unavailable")
|
||||
model_id = self.review_model_id or ""
|
||||
if not model_id:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
|
||||
model = provider.model(model_id)
|
||||
if model is None or not model.vision:
|
||||
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
|
||||
return provider, model
|
||||
|
||||
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
|
||||
"""Require the candidate judge to be a separately configured model."""
|
||||
provider, model = self.resolve_review_model()
|
||||
if provider.id == author_provider.id and model.id == author_model.id:
|
||||
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
|
||||
return provider, model
|
||||
|
||||
|
||||
def _reasoning_effort(value: str) -> str:
|
||||
effort = value.strip().lower()
|
||||
@@ -205,13 +175,6 @@ def get_settings() -> Settings:
|
||||
llm_timeout_s=llm_timeout_s,
|
||||
default_provider_id=default_provider_id,
|
||||
providers=providers,
|
||||
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
|
||||
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
|
||||
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
|
||||
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
|
||||
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
|
||||
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
|
||||
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
|
||||
autonomous_generation=True,
|
||||
# Production instances recover durable runs by default. Test workers
|
||||
# can disable this before startup to guarantee they touch only tasks
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,132 @@
|
||||
"""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",
|
||||
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
|
||||
)
|
||||
tool_ids = _member_sources(
|
||||
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
|
||||
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_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")):
|
||||
# In a targetless FeatureScript body-set operation every selected
|
||||
# member is semantically a tool. Lowering chooses the first source
|
||||
# member only to satisfy CDSL's binary executor shape, so retain that
|
||||
# left operand too when the explicit targetless contract requests it.
|
||||
if bool(params.get("targetless_body_set")):
|
||||
members.update(targets)
|
||||
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,899 @@
|
||||
"""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
|
||||
|
||||
from dataclasses import replace
|
||||
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, TopologyDeltaRelation, TopologyRecord
|
||||
|
||||
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,
|
||||
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
|
||||
"""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. Each non-empty
|
||||
member result retains its own OCC builder history; callers may compose
|
||||
those disjoint exact relations into one operation-wide delta.
|
||||
"""
|
||||
members: dict[str, Any] = {}
|
||||
deltas: list[TopologyDelta] = []
|
||||
for feature_id, body in session.body_members.items():
|
||||
result, delta = session.adapter.cut_with_topology_delta(body, tool)
|
||||
if delta is not None:
|
||||
deltas.append(delta)
|
||||
if result is not None and abs(float(result.volume)) > 1e-12:
|
||||
members[feature_id] = result
|
||||
return members, tuple(deltas)
|
||||
|
||||
|
||||
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
|
||||
"""Combine exact independent member-cut histories without inventing links."""
|
||||
if not deltas or any(delta.history_status != "proven" for delta in deltas):
|
||||
return None
|
||||
return TopologyDelta(
|
||||
operation="subtract",
|
||||
relations=tuple(relation for delta in deltas for relation in delta.relations),
|
||||
section_values=tuple(value for delta in deltas for value in delta.section_values),
|
||||
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
|
||||
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
|
||||
history_reason="per_member_exact_cut_history",
|
||||
)
|
||||
|
||||
|
||||
def _can_register_primary_cut_tool_history(
|
||||
session: "ExecutionSession",
|
||||
tool: Any,
|
||||
topology_delta: TopologyDelta | None,
|
||||
topology_anchors: list[TopologyRecord] | None,
|
||||
) -> bool:
|
||||
"""Return whether a primary REMOVE can retain a transient tool snapshot.
|
||||
|
||||
The implicit CADFS primary boolean normally has no independently active
|
||||
tool body. It may retain its direct-prism input snapshot whenever the
|
||||
tool, profile anchors, and prism builder history are singular and exact.
|
||||
The target may contain several independent members: that changes the cut
|
||||
result cardinality but not the identity of the one transient tool input.
|
||||
This gate does not make any multi-member selector executable; a later
|
||||
resolver must still prove its complete operation-wide relation and active
|
||||
member. The cut builder can independently prove a target-side continuation
|
||||
even when a trimmed or fallback tool has no direct-prism history.
|
||||
"""
|
||||
if (
|
||||
session.body is None
|
||||
or topology_delta is None
|
||||
or topology_delta.operation != "extrude"
|
||||
or topology_delta.history_status != "proven"
|
||||
or not topology_delta.relations
|
||||
or not topology_anchors
|
||||
or len(session.adapter.body_solids(tool)) != 1
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
anchor.kind in {"edge", "vertex"}
|
||||
and (anchor.source_entity is not None or anchor.source_entities)
|
||||
for anchor in topology_anchors
|
||||
)
|
||||
|
||||
|
||||
def _extruded_tool(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
profile_normal: Vector3,
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
record_multiface_prism_history: bool = False,
|
||||
use_operation_wide_prism_history: bool = False,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build an extrude tool, retaining complete direct builder history."""
|
||||
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
|
||||
if (
|
||||
use_operation_wide_prism_history
|
||||
and draft is None
|
||||
and len(faces) > 1
|
||||
and len(extents) == 1
|
||||
and extents[0].trim_to is None
|
||||
):
|
||||
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
|
||||
if composed is not None:
|
||||
return composed
|
||||
topology_deltas: list[tuple[int, TopologyDelta]] = []
|
||||
solids: list[Any] = []
|
||||
exact_two_sided_prism = (
|
||||
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
|
||||
and draft is None
|
||||
and len(faces) == 1
|
||||
and len(extents) == 2
|
||||
and all(extent.trim_to is None for extent in extents)
|
||||
)
|
||||
for face in faces:
|
||||
for extent_index, extent in enumerate(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,
|
||||
)
|
||||
if topology_delta is not None:
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
solids.append(solid)
|
||||
else:
|
||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||
elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and (
|
||||
len(faces) == 1 or record_multiface_prism_history
|
||||
):
|
||||
# Each independently constructed profile face has its own OCC
|
||||
# prism history only when the adapter retained direct source
|
||||
# anchors for every participating profile path. Other complex
|
||||
# multi-face profiles keep the established general-extrude
|
||||
# path; forcing them through MakePrism can make a previously
|
||||
# executable profile invalid without adding usable evidence.
|
||||
try:
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
except ValueError:
|
||||
# Keep the established executable profile result when a
|
||||
# selected IMPRINT region is valid as a face but cannot be
|
||||
# a standalone valid prism. Its later fuse may still be
|
||||
# valid. There is no complete builder witness in this
|
||||
# case, so the entire multi-region topology delta is
|
||||
# withheld below instead of mixing proven and guessed
|
||||
# source anchors.
|
||||
solid = session.adapter.extrude(face, extent.vector)
|
||||
else:
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
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")
|
||||
if len(topology_deltas) != len(solids):
|
||||
return tool, None
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for extent_index, topology_delta in topology_deltas:
|
||||
for relation in topology_delta.relations:
|
||||
# Both prism builders start on the source plane. Its two source
|
||||
# caps are internal to the fused two-sided result. The reverse
|
||||
# extent's far ``LastShape`` is the FeatureScript start cap; map
|
||||
# that exact builder handle before the registry checks final
|
||||
# membership. No source-plane or geometry-derived relation is
|
||||
# promoted to a CAP role.
|
||||
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
|
||||
relation = replace(relation, output_role="extrude.start")
|
||||
relations.append(relation)
|
||||
return tool, TopologyDelta(
|
||||
operation="extrude",
|
||||
relations=tuple(relations),
|
||||
)
|
||||
|
||||
|
||||
def _apply_primary_tool(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
tool: Any,
|
||||
*,
|
||||
cutting: bool,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
topology_anchors: list[TopologyRecord] | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||
topology_predecessors: list[TopologyRecord] | None = None
|
||||
if cutting:
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
members, member_cut_deltas = _cut_explicit_body_members(session, tool)
|
||||
if not members:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
tool_delta = topology_delta
|
||||
tool_anchors = list(topology_anchors or ())
|
||||
retain_transient_tool = _can_register_primary_cut_tool_history(
|
||||
session, tool, tool_delta, topology_anchors,
|
||||
)
|
||||
if len(session.body_members) == 1:
|
||||
member_id = next(iter(session.body_members))
|
||||
body = members[member_id]
|
||||
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
|
||||
# The target-side boolean history is independent of the source
|
||||
# tool's construction history. A trimmed tool cannot support a
|
||||
# source-qualified section query, but its exact BRepAlgoAPI_Cut
|
||||
# Modified/Preserved facts can still prove a unique continuation
|
||||
# of the active target face or edge.
|
||||
topology_anchors = None
|
||||
if cut_delta is not None:
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# Retain cut history as partial diagnostic evidence. The
|
||||
# absent transient source records prevent it from proving
|
||||
# a source-qualified section edge.
|
||||
topology_predecessors = None
|
||||
topology_delta = cut_delta
|
||||
else:
|
||||
topology_delta = None
|
||||
members = {member_id: body}
|
||||
else:
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# A transient tool is optional evidence. Keep the exact
|
||||
# per-member target history when its tool snapshot cannot
|
||||
# be registered completely.
|
||||
topology_predecessors = None
|
||||
body = session.adapter.combine(None, next(iter(members.values())))
|
||||
for member in list(members.values())[1:]:
|
||||
body = session.adapter.combine(body, member)
|
||||
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
|
||||
topology_anchors = 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:
|
||||
# An ADD can replace both the prior active solid and its direct prism
|
||||
# tool. Retain a selector relation only when both snapshots are
|
||||
# singular and OCC supplies the exact union history. The direct-prism
|
||||
# snapshot remains transient: its source anchors cannot be selected
|
||||
# until the union proves a complete successor in the active result.
|
||||
can_trace_add = (
|
||||
session.body is not None
|
||||
and topology_delta is not None
|
||||
and topology_delta.operation == "extrude"
|
||||
and topology_delta.history_status == "proven"
|
||||
and bool(topology_delta.relations)
|
||||
and bool(topology_anchors)
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
)
|
||||
if can_trace_add:
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
|
||||
if fuse_delta is not None:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=topology_delta,
|
||||
topology_anchors=list(topology_anchors or ()),
|
||||
)
|
||||
body = fused
|
||||
topology_delta = fuse_delta
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = fused
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
members = {node.feature_id: body}
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
|
||||
)
|
||||
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),没有闭合区域就无法生成实体。
|
||||
support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
|
||||
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
|
||||
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
|
||||
)
|
||||
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
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
profile = selected_sketch.get("profile") or {}
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
|
||||
isinstance(contour, dict)
|
||||
and bool(contour.get("closed"))
|
||||
and len(contour.get("segments") or []) == 1
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
)
|
||||
planar_imprint_profile = profile.get("type") == "planar_imprint"
|
||||
# 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,
|
||||
# A multi-region prism gets per-region builder history only when
|
||||
# every profile region has at least one exact source boundary.
|
||||
# This covers direct circles and the adapter's bounded IMPRINT
|
||||
# splitter path, without turning arbitrary multi-face profiles
|
||||
# into a different construction algorithm.
|
||||
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
use_operation_wide_prism_history=planar_imprint_profile
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
)
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
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
|
||||
can_record_revolve_history = (
|
||||
node.atomic_id == "revolve_add"
|
||||
and node.params.get("result_mode") == "new_body"
|
||||
and len(faces) == 1
|
||||
)
|
||||
for face in faces:
|
||||
if can_record_revolve_history:
|
||||
try:
|
||||
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
|
||||
except ValueError:
|
||||
# Retain the established executable revolve when OCC
|
||||
# cannot expose a complete builder-history witness.
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
topology_delta = None
|
||||
else:
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
tool = solid if tool is None else session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("revolve produced no solid")
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
parameter: str = "transform_copy_refs",
|
||||
) -> list[str]:
|
||||
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get(parameter) 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,
|
||||
transform_copy_parameter: str = "transform_copy_refs",
|
||||
) -> 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, transform_copy_parameter))
|
||||
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")
|
||||
spatial = path.get("workplane") is None
|
||||
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||
segment = path.get("segment") or {}
|
||||
segments = path.get("segments")
|
||||
|
||||
def local_point(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
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 local_vector(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
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])))
|
||||
|
||||
if segments is not None:
|
||||
if segment:
|
||||
raise ValueError("sweep path cannot mix segment and segments")
|
||||
if not isinstance(segments, list) or len(segments) < 2:
|
||||
raise ValueError("sweep segmented path requires at least two segments")
|
||||
materialized: list[dict[str, Any]] = []
|
||||
for index, source in enumerate(segments):
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(source.get("type") or "")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
target: dict[str, Any] = {"type": kind}
|
||||
if spatial:
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = source.get("start_mm")
|
||||
target["end_mm"] = source.get("end_mm")
|
||||
if kind == "arc":
|
||||
target["center_mm"] = source.get("center_mm")
|
||||
target["normal"] = source.get("normal")
|
||||
target["radius_mm"] = source.get("radius_mm")
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
target["points_mm"] = source.get("points_mm")
|
||||
if source.get("start_tangent_mm") is not None:
|
||||
target["start_tangent_mm"] = source.get("start_tangent_mm")
|
||||
if source.get("end_tangent_mm") is not None:
|
||||
target["end_tangent_mm"] = source.get("end_tangent_mm")
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
continue
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = local_point(source.get("start"))
|
||||
target["end_mm"] = local_point(source.get("end"))
|
||||
if kind == "arc":
|
||||
target["center_mm"] = local_point(source.get("center"))
|
||||
target["normal"] = plane.normal
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
points = source.get("points")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
raise ValueError("sweep B-spline path requires at least two interpolation points")
|
||||
target["points_mm"] = [local_point(value) for value in points]
|
||||
if source.get("start_tangent") is not None:
|
||||
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
|
||||
if source.get("end_tangent") is not None:
|
||||
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
return session.adapter.sweep_path_segments(materialized)
|
||||
|
||||
if spatial:
|
||||
raise ValueError("spatial sweep path requires captured segments")
|
||||
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 == "circle":
|
||||
return session.adapter.sweep_circle_path(
|
||||
local_point(segment.get("center")),
|
||||
plane.x_dir,
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
)
|
||||
elif kind == "arc":
|
||||
return session.adapter.sweep_arc_path(
|
||||
local_point(segment.get("start")),
|
||||
local_point(segment.get("end")),
|
||||
local_point(segment.get("center")),
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
clockwise=bool(segment["clockwise"]),
|
||||
)
|
||||
elif kind == "bspline":
|
||||
local_points = segment.get("points") or []
|
||||
else:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
if len(local_points) < 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
|
||||
return session.adapter.sweep_path(
|
||||
[local_point(value) for value in local_points],
|
||||
start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
|
||||
end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
|
||||
parameters=[float(value) for value in segment.get("parameters") or []] or None,
|
||||
)
|
||||
|
||||
|
||||
def _register_added_solid(
|
||||
session: "ExecutionSession",
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
*,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
) -> 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,
|
||||
topology_delta=topology_delta,
|
||||
)
|
||||
return
|
||||
if session.body is None:
|
||||
session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta)
|
||||
return
|
||||
if topology_delta is None:
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
return
|
||||
# Preserve primitive output roles only through the exact OCC fuse history.
|
||||
# The transient records provide source handles, not selectable snapshots.
|
||||
role_records = _direct_output_role_records(session, node, solid, topology_delta)
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid)
|
||||
session.register_body(
|
||||
node.feature_id, fused, replay_node=node, topology_delta=fuse_delta,
|
||||
topology_predecessors=role_records,
|
||||
)
|
||||
|
||||
|
||||
def _direct_output_role_records(
|
||||
session: "ExecutionSession",
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
topology_delta: TopologyDelta,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Attach only builder-proven output roles to a transient primitive snapshot."""
|
||||
records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}")
|
||||
result: list[TopologyRecord] = []
|
||||
for record in records:
|
||||
roles = {
|
||||
relation.output_role
|
||||
for relation in topology_delta.relations
|
||||
if relation.output_role is not None
|
||||
and relation.kind == record.kind
|
||||
and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values)
|
||||
}
|
||||
if roles:
|
||||
result.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
kind=record.kind,
|
||||
feature_id=record.feature_id,
|
||||
body_id=record.body_id,
|
||||
geometry=record.geometry,
|
||||
value=record.value,
|
||||
owner_feature_ids=(node.feature_id,),
|
||||
output_roles=tuple(sorted(roles)),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
|
||||
if resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
|
||||
return adapter.planar_face_workplane(resolution.record.value)
|
||||
|
||||
|
||||
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:
|
||||
records = item.records or ((item.record,) if item.record is not None else ())
|
||||
for record in records:
|
||||
if record.kind == "edge":
|
||||
edges.append(record.value)
|
||||
elif record.kind == "face":
|
||||
edges.extend(edge for edge in 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 = [
|
||||
record
|
||||
for item in resolved
|
||||
for record in (item.records or ((item.record,) if item.record is not None else ()))
|
||||
]
|
||||
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,89 @@
|
||||
"""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
|
||||
|
||||
import math
|
||||
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)
|
||||
|
||||
|
||||
@atomic_executor("reference_point")
|
||||
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Execute an explicit source-datum point without changing model topology."""
|
||||
del sketch
|
||||
point = node.params.get("point_mm")
|
||||
if (
|
||||
not isinstance(point, list)
|
||||
or len(point) != 3
|
||||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
|
||||
):
|
||||
raise ValueError("reference point requires one finite three-dimensional point")
|
||||
return session.result(node, include_body=False)
|
||||
|
||||
|
||||
@atomic_executor("assign_variable")
|
||||
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Replay a source variable declaration after lowering has consumed it."""
|
||||
del sketch
|
||||
name = node.params.get("name")
|
||||
value = node.params.get("value")
|
||||
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise ValueError("assign_variable requires one finite named value")
|
||||
return session.result(node, include_body=False)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""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 _single_member_dressup_members(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
body: Any,
|
||||
selected_edges: list[Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Preserve unchanged body members after one exact-member dress-up.
|
||||
|
||||
The adapter only reports Compound history when every selected edge maps to
|
||||
one source solid. Keep the same proof at the body-graph layer: all other
|
||||
members must appear unchanged in the result and exactly one result solid
|
||||
must remain for the changed member. Otherwise aggregate replay remains
|
||||
valid, but no member-lifecycle transfer is asserted.
|
||||
"""
|
||||
if not selected_edges or len(session.body_members) < 2:
|
||||
return None
|
||||
source_members: dict[str, Any] = {}
|
||||
for member_id, member in session.body_members.items():
|
||||
solids = session.adapter.body_solids(member)
|
||||
if len(solids) != 1:
|
||||
return None
|
||||
source_members[member_id] = solids[0]
|
||||
selected_members = {
|
||||
member_id
|
||||
for edge in selected_edges
|
||||
for member_id, member in source_members.items()
|
||||
if any(edge.is_same(candidate) for candidate in member.edges())
|
||||
}
|
||||
if len(selected_members) != 1:
|
||||
return None
|
||||
changed_member_id = next(iter(selected_members))
|
||||
result_solids = session.adapter.body_solids(body)
|
||||
unchanged: dict[str, Any] = {}
|
||||
matched_result_indexes: set[int] = set()
|
||||
for member_id, member in source_members.items():
|
||||
if member_id == changed_member_id:
|
||||
continue
|
||||
matches = [
|
||||
index for index, result in enumerate(result_solids)
|
||||
if session.topology._same_topology_value(member, result)
|
||||
]
|
||||
if len(matches) != 1 or matches[0] in matched_result_indexes:
|
||||
return None
|
||||
matched_result_indexes.add(matches[0])
|
||||
unchanged[member_id] = result_solids[matches[0]]
|
||||
changed = [
|
||||
result for index, result in enumerate(result_solids)
|
||||
if index not in matched_result_indexes
|
||||
]
|
||||
if len(changed) != 1:
|
||||
return None
|
||||
return {**unchanged, node.feature_id: changed[0]}
|
||||
|
||||
|
||||
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 相切传播),并执行圆角。
|
||||
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||
body, topology_delta = session.adapter.fillet_with_topology_delta(
|
||||
session.body, radius, edges,
|
||||
)
|
||||
# 4. 登记新主体并返回结果。
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
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,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
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,113 @@
|
||||
"""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")
|
||||
scope_feature_id = node.params.get("scope_feature_id")
|
||||
scoped_body = None
|
||||
if scope_feature_id is not None:
|
||||
if not isinstance(scope_feature_id, str) or not scope_feature_id:
|
||||
raise ValueError("hole scope_feature_id is invalid")
|
||||
scoped_body = session.body_members.get(scope_feature_id)
|
||||
if scoped_body is None:
|
||||
raise ValueError("hole scope body is no longer an independently selectable member")
|
||||
scoped_solids = session.adapter.body_solids(scoped_body)
|
||||
if not scoped_solids:
|
||||
raise ValueError("hole scope body has no active solid")
|
||||
# 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), session.adapter)
|
||||
intent = selector.get("selector_intent") if isinstance(selector, dict) else None
|
||||
# Existing selector-hosted holes store world positions. Only the new
|
||||
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
|
||||
# until its exact host relation has materialized.
|
||||
positions_are_local = (
|
||||
isinstance(intent, dict)
|
||||
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
|
||||
)
|
||||
# 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(scoped_body or 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,
|
||||
))
|
||||
if scope_feature_id is None:
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = None
|
||||
else:
|
||||
# CADFS ``scope`` identifies the target body member. Never apply the
|
||||
# cutter to the aggregate merely because unrelated live members share
|
||||
# the exported part; unchanged members remain exact body-graph nodes.
|
||||
result_body = session.adapter.cut(scoped_body, tool)
|
||||
members = {**session.body_members, scope_feature_id: result_body}
|
||||
body = None
|
||||
for member in members.values():
|
||||
body = session.adapter.combine(body, member)
|
||||
if body is None:
|
||||
raise ValueError("hole scope cut produced no active body")
|
||||
result_body = body
|
||||
session.register_body(
|
||||
node.feature_id, result_body, replay_node=node, body_members=members,
|
||||
)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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, TopologyRecord
|
||||
from .common import _apply_primary_tool, _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, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(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)),
|
||||
)
|
||||
if node.atomic_id == "sweep_cut":
|
||||
# The PipeShell is a transient cutting tool. Its own builder history
|
||||
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
|
||||
# the cut's exact target-side delta may survive registration.
|
||||
return _apply_primary_tool(node, session, solid, cutting=True)
|
||||
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
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
if kind not in {"edge", "vertex"} or spec.get("value") is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if not isinstance(source_entity, tuple) and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=spec["value"],
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@atomic_executor("sweep_cut")
|
||||
def _sweep_cut_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, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis)
|
||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid, topology_delta=topology_delta)
|
||||
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,93 @@
|
||||
"""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)
|
||||
|
||||
|
||||
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
profile = session.sketches.get(str(sketch_id))
|
||||
if profile is None:
|
||||
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(profile)
|
||||
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
|
||||
return session.result(node, include_body=False, surface_id=surface_id)
|
||||
|
||||
|
||||
@atomic_executor("loft_surface")
|
||||
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_surface(node, session)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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
|
||||
|
||||
import math
|
||||
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:
|
||||
reference = _extent_reference(node, end_condition) if condition != "through_next" else None
|
||||
source_vertex_point: Vector3 | None = None
|
||||
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
|
||||
raw_point = reference.get("point_mm")
|
||||
if not (
|
||||
isinstance(raw_point, list)
|
||||
and len(raw_point) == 3
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
|
||||
):
|
||||
raise FeatureExecutionError(
|
||||
"invalid_source_vertex_extent",
|
||||
"The source-vertex extent datum must contain one finite 3D point",
|
||||
extent=condition,
|
||||
)
|
||||
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
|
||||
elif 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
|
||||
elif source_vertex_point is not None:
|
||||
target = None
|
||||
else:
|
||||
assert isinstance(reference, dict)
|
||||
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 = source_vertex_point or 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)
|
||||
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
|
||||
# Do not reinterpret a partially hit finite face: that path
|
||||
# has explicit trimmed-solid semantics below. Only a wholly
|
||||
# unreachable planar face may terminate on its supporting
|
||||
# plane, and the adapter proves one positive, uniform
|
||||
# profile-to-plane distance before returning it.
|
||||
if not session.adapter.target_has_forward_intersection(target, faces, direction):
|
||||
try:
|
||||
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return ExtentVector(vector_scale(direction, distance))
|
||||
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,14 +30,32 @@ 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")
|
||||
nested_selector_policies = raw.get("nested_selector_policies") or {}
|
||||
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)
|
||||
or not isinstance(nested_selector_policies, 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):
|
||||
raise ValueError(f"operation author params are invalid for {atomic_id}")
|
||||
if not all(isinstance(name, str) and name in properties for name in required):
|
||||
raise ValueError(f"operation required params are invalid for {atomic_id}")
|
||||
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
|
||||
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
|
||||
materialized_required = list(dict.fromkeys(required))
|
||||
for path in injected_paths:
|
||||
if not isinstance(path, str):
|
||||
@@ -51,5 +69,9 @@ 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"),
|
||||
"nested_selector_policies": dict(nested_selector_policies),
|
||||
"runtime_capability": dict(runtime_capability),
|
||||
}
|
||||
return derived
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Parametric equal-thickness sheet-metal bend generator (``bend_add``).
|
||||
|
||||
A bent sheet is an equal-thickness ribbon around its mid-plane centreline:
|
||||
straight wings joined by tangent circular-arc corners of radius
|
||||
``r_m = r_i + t/2``. We walk the wing chain in the local XY bending plane
|
||||
(first wing along +X), round each fold vertex with ``fillet_2d``, offset the
|
||||
centreline by ``+t/2`` / ``-t/2``, close both offset curves with square end
|
||||
caps and extrude by ``width_mm`` along +Z. Cross-section area is exactly
|
||||
``thickness * centreline_length`` so the closed-form volume is
|
||||
``t * w * L_mid`` with ``L_mid = sum(leg) - sum(2 r_m cot(alpha/2)) +
|
||||
sum(r_m (pi - alpha))`` (alpha = interior angle in radians). Geometric-
|
||||
equivalence only: no K-factor flattening, so the volume deliberately differs
|
||||
from the flat blank. Local frame: +X = first wing, +Y = thickness normal,
|
||||
+Z = fold (width) axis, first wing starts at the origin. The module reads
|
||||
only ``runtime_types.BendSpec`` and returns an OCC ``Solid``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from build123d import Edge, Face, Side, Solid, Vector, Wire
|
||||
|
||||
try: # build123d >= 0.9 exposes Kind; keep the import optional.
|
||||
from build123d import Kind as _Kind
|
||||
|
||||
_KIND_ARC = _Kind.ARC
|
||||
except ImportError: # pragma: no cover
|
||||
_KIND_ARC = None
|
||||
|
||||
from .runtime_types import BendLeg, BendSpec
|
||||
|
||||
|
||||
def _unit(angle_deg: float) -> tuple[float, float]:
|
||||
angle = math.radians(angle_deg)
|
||||
return math.cos(angle), math.sin(angle)
|
||||
|
||||
|
||||
def _perp(value: Vector) -> Vector:
|
||||
return Vector(-value.Y, value.X, 0.0)
|
||||
|
||||
|
||||
def bend_folds(spec: BendSpec) -> tuple[list[float], list[tuple[float, float, float, int]]]:
|
||||
"""Return ``(legs, folds)``; ``folds[i] = (angle, r_i, r_m, side)``."""
|
||||
if not spec.chain:
|
||||
raise ValueError("bend chain must contain at least one wing")
|
||||
legs = [float(leg.leg_mm) for leg in spec.chain]
|
||||
folds: list[tuple[float, float, float, int]] = []
|
||||
for index in range(len(spec.chain) - 1):
|
||||
leg: BendLeg = spec.chain[index]
|
||||
if leg.bend_angle_deg is None:
|
||||
raise ValueError(f"bend chain[{index}] needs bend_angle_deg towards the next wing")
|
||||
angle = float(leg.bend_angle_deg)
|
||||
if not 0 < angle < 180:
|
||||
raise ValueError("bend interior angle must be between 0 and 180 degrees")
|
||||
radius = float(leg.inner_radius_mm)
|
||||
if radius < 0:
|
||||
raise ValueError("bend inner radius must be non-negative")
|
||||
folds.append((angle, radius, radius + spec.thickness_mm / 2.0, int(leg.side)))
|
||||
return legs, folds
|
||||
|
||||
|
||||
def fold_vertices(legs: list[float], folds: list[tuple[float, float, float, int]]) -> list[Vector]:
|
||||
"""Fold-vertex polyline (both end points included) in the XY plane."""
|
||||
angle = 0.0
|
||||
points = [Vector(0.0, 0.0, 0.0)]
|
||||
x = y = 0.0
|
||||
for index in range(len(legs)):
|
||||
dx, dy = _unit(angle)
|
||||
x += dx * legs[index]
|
||||
y += dy * legs[index]
|
||||
points.append(Vector(x, y, 0.0))
|
||||
if index < len(folds):
|
||||
bend_angle_deg, _r_i, _r_m, side = folds[index]
|
||||
angle += float(side) * (180.0 - bend_angle_deg)
|
||||
return points
|
||||
|
||||
|
||||
def mid_path_length_mm(spec: BendSpec) -> float:
|
||||
"""Exact centreline length of the bent part (mm)."""
|
||||
legs, folds = bend_folds(spec)
|
||||
total = sum(legs)
|
||||
for bend_angle_deg, _r_i, mid_radius, _side in folds:
|
||||
delta = math.radians(180.0 - bend_angle_deg)
|
||||
cut = mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0)
|
||||
total -= 2.0 * cut
|
||||
total += mid_radius * delta
|
||||
return total
|
||||
|
||||
|
||||
def validate_chain(spec: BendSpec) -> float:
|
||||
"""Range-check a bend spec; return the centreline length.
|
||||
|
||||
Every wing must keep a positive straight portion after its neighbouring
|
||||
bend corners consume their tangent lengths (otherwise corners overlap).
|
||||
"""
|
||||
if spec.thickness_mm <= 0 or spec.width_mm <= 0:
|
||||
raise ValueError("bend requires positive thickness_mm and width_mm")
|
||||
legs, folds = bend_folds(spec)
|
||||
cuts: list[float] = []
|
||||
for bend_angle_deg, _r_i, mid_radius, _side in folds:
|
||||
cuts.append(mid_radius / math.tan(math.radians(bend_angle_deg) / 2.0))
|
||||
for index in range(len(legs)):
|
||||
left = cuts[index - 1] if index > 0 else 0.0
|
||||
right = cuts[index] if index < len(folds) else 0.0
|
||||
if legs[index] - left - right <= 0:
|
||||
raise ValueError(
|
||||
f"bend wing {index} (leg {legs[index]:.4f} mm) is consumed by the adjacent "
|
||||
f"bend radii (needs > {left + right:.4f} mm)"
|
||||
)
|
||||
return mid_path_length_mm(spec)
|
||||
|
||||
|
||||
def _centerline_wire(legs, folds):
|
||||
"""Tangent-continuous filleted centreline wire in the XY plane."""
|
||||
points = fold_vertices(legs, folds)
|
||||
wire = Wire([Edge.make_line(points[i], points[i + 1]) for i in range(len(points) - 1)])
|
||||
for index, (bend_angle_deg, _r_i, mid_radius, _side) in enumerate(folds):
|
||||
if mid_radius <= 0:
|
||||
continue
|
||||
target = points[index + 1]
|
||||
selected = [
|
||||
vertex for vertex in wire.vertices()
|
||||
if (vertex.X - target.X) ** 2 + (vertex.Y - target.Y) ** 2 < 1e-9
|
||||
]
|
||||
if not selected:
|
||||
raise ValueError(f"bend corner {index} vertex not found")
|
||||
try:
|
||||
wire = wire.fillet_2d(mid_radius, selected)
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
raise ValueError(
|
||||
f"bend corner {index} fillet of radius {mid_radius:.4f} mm failed"
|
||||
) from error
|
||||
return wire, points
|
||||
|
||||
|
||||
def build_bend_solid(spec: BendSpec) -> Solid:
|
||||
"""Build the bent sheet in the local frame (+Z = width axis).
|
||||
|
||||
The first wing's mid-plane runs from the origin along +X; thickness spans
|
||||
+/- t/2 across the XY mid-plane and the width spans z in [0, width_mm].
|
||||
The caller (geometry adapter) places the solid at ``spec.frame``.
|
||||
"""
|
||||
thickness = float(spec.thickness_mm)
|
||||
width = float(spec.width_mm)
|
||||
legs, folds = bend_folds(spec)
|
||||
validate_chain(spec)
|
||||
half = thickness / 2.0
|
||||
wire, points = _centerline_wire(legs, folds)
|
||||
left = wire.offset_2d(half, kind=_KIND_ARC, side=Side.LEFT, closed=False)
|
||||
right = wire.offset_2d(half, kind=_KIND_ARC, side=Side.RIGHT, closed=False)
|
||||
start_tangent = (points[1] - points[0]).normalized()
|
||||
end_tangent = (points[-1] - points[-2]).normalized()
|
||||
cap_start = Edge.make_line(points[0] + _perp(start_tangent) * half, points[0] - _perp(start_tangent) * half)
|
||||
cap_end = Edge.make_line(points[-1] + _perp(end_tangent) * half, points[-1] - _perp(end_tangent) * half)
|
||||
closed = Wire([*left.edges(), cap_end, *right.edges(), cap_start])
|
||||
return Solid.extrude(Face(closed), Vector(0.0, 0.0, width))
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Parametric involute gear and rack generator (``gear_add`` / ``rack_add``).
|
||||
|
||||
Strategy
|
||||
--------
|
||||
A standard involute gear profile is sampled analytically in the end plane:
|
||||
pitch radius ``r = m*z/2``, base radius ``r_b = r*cos(alpha)``, tip radius
|
||||
``r + m`` and root radius ``r - 1.25*m``. Each tooth flank is the involute
|
||||
of the base circle parameterised by the roll angle ``t``
|
||||
|
||||
x(t) = r_b*(cos t + t*sin t), y(t) = r_b*(sin t - t*cos t)
|
||||
|
||||
rotated by ``delta = psi - inv(alpha)`` (``psi = pi/(2z)`` is the half tooth
|
||||
thickness angle on the pitch circle) so the flank passes through the pitch
|
||||
point at the correct tooth thickness. The closed section polygon per tooth
|
||||
period is: root arc (from the valley centre) -> left flank (root to tip) ->
|
||||
tip arc (through the tooth centre) -> right flank (tip to root) -> root arc
|
||||
(to the next valley centre). When ``r_f < r_b`` the involute starts on the
|
||||
base circle and a radial foot joins it down to the root circle.
|
||||
|
||||
* Spur gear (``helix_angle_rad == 0``): the end face is extruded linearly.
|
||||
* Helical gear: ``Solid.extrude_linear_with_rotation`` builds a true twisted
|
||||
prism - the section rotates by ``width*tan(beta)/r_pitch`` while extruding
|
||||
over ``width_mm`` - which is exactly the involute-helicoid tooth surface;
|
||||
every ``z = const`` cross-section is the same rotated profile so the volume
|
||||
is exactly ``section_area * width``.
|
||||
* Herringbone (double helical): the upper half is the twisted prism above;
|
||||
the lower half is its mirror image about the ``z = width/2`` plane, which
|
||||
reverses the helix while keeping the mid-plane section phase-continuous.
|
||||
The two halves meet on the shared mid-plane section; ``fuse`` plus
|
||||
``clean`` merge them into one solid (the seam is a same-shape section, and
|
||||
OCC handles it reliably, verified by exact volume ``2 * upper``).
|
||||
|
||||
The rack is the linear counterpart: pitch ``p = pi*m``, trapezoid teeth with
|
||||
flanks inclined by ``pressure_angle_rad``, addendum ``m`` and dedendum
|
||||
``1.25*m``. Both ends land in valley centres so the exact length is
|
||||
``teeth_count * pi * m`` and the cross-section area has the closed form
|
||||
``L*h_f + n*(b_root + b_tip)*h_a``.
|
||||
|
||||
Local frames: the gear axis is +Z with the ``z = 0`` end face centred on the
|
||||
origin; the rack runs along +X, teeth point along +Z with thickness along
|
||||
+Y. Placement onto ``spec.axis`` is the adapter's responsibility. The
|
||||
module only reads ``runtime_types.GearSpec`` / ``RackSpec`` and returns OCC
|
||||
``Solid`` objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from build123d import Edge, Face, Plane, Solid, Vector, Wire
|
||||
|
||||
from .runtime_types import GearSpec, RackSpec
|
||||
|
||||
#: Involute samples per tooth flank (chord error << 1e-3 mm at module 1).
|
||||
_SAMPLES_PER_FLANK = 16
|
||||
#: Maximum angular step (radians) when sampling tip/root arcs.
|
||||
_ARC_STEP_RAD = math.radians(2.0)
|
||||
#: Consecutive-point deduplication tolerance (mm).
|
||||
_MERGE_TOL = 1e-9
|
||||
|
||||
|
||||
def _rotate(point: tuple[float, float], angle: float) -> tuple[float, float]:
|
||||
return (
|
||||
point[0] * math.cos(angle) - point[1] * math.sin(angle),
|
||||
point[0] * math.sin(angle) + point[1] * math.cos(angle),
|
||||
)
|
||||
|
||||
|
||||
def involute_geometry(spec: GearSpec) -> dict[str, float]:
|
||||
"""Derive the analytic involute geometry of a gear spec.
|
||||
|
||||
Returns pitch/base/tip/root radii, the half tooth thickness angle on the
|
||||
pitch circle (``psi``), the base-circle start offset (``delta``), the
|
||||
flank roll-angle window ``[t_start, t_tip]`` and the polar half-angles of
|
||||
the root/tip points (``a_root`` / ``a_tip``, measured from the tooth
|
||||
centre line).
|
||||
"""
|
||||
r = spec.pitch_radius_mm
|
||||
rb = spec.base_radius_mm
|
||||
ra = spec.tip_radius_mm
|
||||
rf = spec.root_radius_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
z = spec.teeth_count
|
||||
if rb <= 0.0 or ra <= rb:
|
||||
raise ValueError("gear tip radius must exceed the base radius")
|
||||
psi = math.pi / (2.0 * z)
|
||||
delta = psi - (math.tan(alpha) - alpha)
|
||||
t_tip = math.sqrt((ra / rb) ** 2 - 1.0)
|
||||
beta_tip = t_tip - math.atan(t_tip)
|
||||
t_start = math.sqrt((rf / rb) ** 2 - 1.0) if rf >= rb else 0.0
|
||||
beta_start = t_start - math.atan(t_start)
|
||||
a_tip = delta + beta_tip
|
||||
a_root = delta + beta_start
|
||||
if not a_tip < math.pi / z:
|
||||
raise ValueError("gear tooth tip arcs overlap; reduce module or add teeth")
|
||||
if not a_root < math.pi / z:
|
||||
raise ValueError("gear tooth root arcs overlap; gear geometry is degenerate")
|
||||
return {
|
||||
"r": r, "rb": rb, "ra": ra, "rf": rf, "psi": psi, "delta": delta,
|
||||
"t_start": t_start, "t_tip": t_tip, "a_tip": a_tip, "a_root": a_root,
|
||||
}
|
||||
|
||||
|
||||
def right_flank_points(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
||||
"""Right tooth flank (root -> tip) with the tooth centred on angle 0.
|
||||
|
||||
Points lie exactly on the involute; when ``r_f < r_b`` the first point is
|
||||
the radial foot on the root circle (same polar angle as the base-circle
|
||||
start) so the section stays simply connected.
|
||||
"""
|
||||
geom = involute_geometry(spec)
|
||||
rb, delta = geom["rb"], geom["delta"]
|
||||
points: list[tuple[float, float]] = []
|
||||
if spec.root_radius_mm < rb:
|
||||
points.append((spec.root_radius_mm * math.cos(delta), spec.root_radius_mm * math.sin(delta)))
|
||||
for index in range(samples):
|
||||
t = geom["t_start"] + (geom["t_tip"] - geom["t_start"]) * index / (samples - 1)
|
||||
x0 = rb * (math.cos(t) + t * math.sin(t))
|
||||
y0 = rb * (math.sin(t) - t * math.cos(t))
|
||||
points.append(_rotate((x0, y0), delta))
|
||||
return points
|
||||
|
||||
|
||||
def _arc_points(radius: float, start_angle: float, end_angle: float) -> list[tuple[float, float]]:
|
||||
"""Sample a circular arc (inclusive of both ends) at ``_ARC_STEP_RAD``."""
|
||||
step = max(1, math.ceil(abs(end_angle - start_angle) / _ARC_STEP_RAD))
|
||||
return [
|
||||
_rotate((radius, 0.0), start_angle + (end_angle - start_angle) * k / step)
|
||||
for k in range(step + 1)
|
||||
]
|
||||
|
||||
|
||||
def spur_profile_polygon(spec: GearSpec, samples: int = _SAMPLES_PER_FLANK) -> list[tuple[float, float]]:
|
||||
"""Closed end-plane section polygon of the full gear (counter-clockwise).
|
||||
|
||||
One period per tooth: valley centre -> root arc -> left flank (root to
|
||||
tip) -> tip arc (through the tooth centre) -> right flank (tip to root)
|
||||
-> root arc to the next valley centre. The polygon is a pure polyline so
|
||||
its shoelace area equals the OCC face area exactly.
|
||||
"""
|
||||
geom = involute_geometry(spec)
|
||||
ra, rf = geom["ra"], geom["rf"]
|
||||
a_tip, a_root = geom["a_tip"], geom["a_root"]
|
||||
period = 2.0 * math.pi / spec.teeth_count
|
||||
flank = right_flank_points(spec, samples)
|
||||
left_flank = [(x, -y) for (x, y) in flank]
|
||||
points: list[tuple[float, float]] = []
|
||||
for tooth in range(spec.teeth_count):
|
||||
gamma = tooth * period
|
||||
points.extend(_arc_points(rf, gamma - period / 2.0, gamma - a_root))
|
||||
points.extend(_rotate(point, gamma) for point in left_flank)
|
||||
points.extend(_arc_points(ra, gamma - a_tip, gamma + a_tip))
|
||||
points.extend(_rotate(point, gamma) for point in reversed(flank))
|
||||
points.extend(_arc_points(rf, gamma + a_root, gamma + period / 2.0))
|
||||
merged = _merge_consecutive(points)
|
||||
if polygon_area(merged) < 0.0:
|
||||
merged.reverse()
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_consecutive(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Drop consecutive (and closing) duplicate points within ``_MERGE_TOL``."""
|
||||
merged: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
if merged and math.dist(merged[-1], point) <= _MERGE_TOL:
|
||||
continue
|
||||
merged.append(point)
|
||||
if len(merged) > 1 and math.dist(merged[0], merged[-1]) <= _MERGE_TOL:
|
||||
merged.pop()
|
||||
return merged
|
||||
|
||||
|
||||
def polygon_area(points: list[tuple[float, float]]) -> float:
|
||||
"""Signed shoelace area (positive = counter-clockwise)."""
|
||||
total = 0.0
|
||||
count = len(points)
|
||||
for index in range(count):
|
||||
x0, y0 = points[index]
|
||||
x1, y1 = points[(index + 1) % count]
|
||||
total += x0 * y1 - x1 * y0
|
||||
return total / 2.0
|
||||
|
||||
|
||||
def _section_wire(spec: GearSpec, theta_offset: float = 0.0, z: float = 0.0) -> Wire:
|
||||
"""End-plane section wire rotated by ``theta_offset`` and lifted to ``z``."""
|
||||
points = spur_profile_polygon(spec)
|
||||
if theta_offset:
|
||||
points = [_rotate(point, theta_offset) for point in points]
|
||||
vertices = [Vector(x, y, z) for (x, y) in points]
|
||||
return Wire([
|
||||
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
||||
for index in range(len(vertices))
|
||||
])
|
||||
|
||||
|
||||
def helix_twist_angle_rad(spec: GearSpec) -> float:
|
||||
"""Total section rotation over the full width for a helical gear."""
|
||||
if spec.helix_angle_rad <= 0.0:
|
||||
return 0.0
|
||||
return math.tan(spec.helix_angle_rad) * spec.width_mm / spec.pitch_radius_mm
|
||||
|
||||
|
||||
def build_gear_solid(spec: GearSpec) -> Solid:
|
||||
"""Build the gear in the local frame (+Z = axis, ``z in [0, width_mm]``)."""
|
||||
involute_geometry(spec) # validation
|
||||
face = Face(_section_wire(spec))
|
||||
width = spec.width_mm
|
||||
if spec.helix_angle_rad <= 0.0:
|
||||
return Solid.extrude(face, Vector(0.0, 0.0, width))
|
||||
twist = helix_twist_angle_rad(spec)
|
||||
if not spec.herringbone:
|
||||
return Solid.extrude_linear_with_rotation(
|
||||
face, (0.0, 0.0, 0.0), (0.0, 0.0, width), math.degrees(twist),
|
||||
)
|
||||
# Herringbone: the upper half twists 0 -> +A/2; its mirror image about
|
||||
# the mid-width plane reverses the helix with a phase-continuous seam.
|
||||
half = width / 2.0
|
||||
upper = Solid.extrude_linear_with_rotation(
|
||||
face, (0.0, 0.0, 0.0), (0.0, 0.0, half), math.degrees(twist / 2.0),
|
||||
)
|
||||
mirrored = upper.mirror(Plane(origin=(0.0, 0.0, half), z_dir=(0.0, 0.0, 1.0)))
|
||||
merged = upper.fuse(mirrored).clean()
|
||||
if isinstance(merged, Solid):
|
||||
return merged
|
||||
solids = merged.solids()
|
||||
if len(solids) == 1:
|
||||
return solids[0]
|
||||
raise ValueError("herringbone gear fuse produced a non-single body")
|
||||
|
||||
|
||||
def rack_profile_polygon(spec: RackSpec) -> list[tuple[float, float]]:
|
||||
"""Closed rack cross-section polygon in the local ``(x, z)`` plane.
|
||||
|
||||
``x`` runs along the rack from 0 to ``teeth_count * pi * m`` (both ends
|
||||
in valley centres); ``z`` runs from the root/backing plane (0) to the
|
||||
crest plane (``2.25 * m``).
|
||||
"""
|
||||
m = spec.module_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
pitch = spec.pitch_mm
|
||||
addendum = spec.addendum_mm
|
||||
dedendum = spec.dedendum_mm
|
||||
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
||||
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
||||
if half_tip <= 0.0:
|
||||
raise ValueError("rack pressure angle consumes the whole tooth crest")
|
||||
length = spec.length_mm
|
||||
points: list[tuple[float, float]] = [(0.0, 0.0), (length, 0.0), (length, dedendum)]
|
||||
for tooth in reversed(range(spec.teeth_count)):
|
||||
centre = (tooth + 0.5) * pitch
|
||||
points.append((centre + half_root, dedendum))
|
||||
points.append((centre + half_tip, dedendum + addendum))
|
||||
points.append((centre - half_tip, dedendum + addendum))
|
||||
points.append((centre - half_root, dedendum))
|
||||
points.append((0.0, dedendum))
|
||||
merged = _merge_consecutive(points)
|
||||
if polygon_area(merged) < 0.0:
|
||||
merged.reverse()
|
||||
return merged
|
||||
|
||||
|
||||
def rack_section_area_mm2(spec: RackSpec) -> float:
|
||||
"""Closed-form rack cross-section area (shoelace-exact, all edges straight)."""
|
||||
pitch = spec.pitch_mm
|
||||
addendum = spec.addendum_mm
|
||||
alpha = spec.pressure_angle_rad
|
||||
half_root = pitch / 4.0 + addendum * math.tan(alpha)
|
||||
half_tip = pitch / 4.0 - addendum * math.tan(alpha)
|
||||
return spec.length_mm * spec.dedendum_mm + spec.teeth_count * (half_root + half_tip) * addendum
|
||||
|
||||
|
||||
def build_rack_solid(spec: RackSpec) -> Solid:
|
||||
"""Build the rack in the local frame (+X length, +Y thickness, +Z teeth)."""
|
||||
points = rack_profile_polygon(spec)
|
||||
vertices = [Vector(x, 0.0, z) for (x, z) in points]
|
||||
wire = Wire([
|
||||
Edge.make_line(vertices[index], vertices[(index + 1) % len(vertices)])
|
||||
for index in range(len(vertices))
|
||||
])
|
||||
return Solid.extrude(Face(wire), Vector(0.0, spec.thickness_mm, 0.0))
|
||||
@@ -0,0 +1,531 @@
|
||||
"""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))
|
||||
elif isinstance(path, dict) and isinstance(path.get("segments"), list):
|
||||
# A source-only spatial sweep path has no common workplane. Its
|
||||
# captured points and directions are absolute, so replayed circular
|
||||
# pattern instances must rotate each geometric field independently.
|
||||
for segment in path["segments"]:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("start_mm", "end_mm", "center_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = _rotated_point(value, axis, angle_rad)
|
||||
points = segment.get("points_mm")
|
||||
if isinstance(points, list):
|
||||
segment["points_mm"] = [
|
||||
_rotated_point(point, axis, angle_rad)
|
||||
for point in points
|
||||
if isinstance(point, list) and len(point) == 3
|
||||
]
|
||||
for key in ("normal", "start_tangent_mm", "end_tangent_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = list(_rotated_vector(tuple(float(v) for v in value), 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
|
||||
@@ -18,7 +18,7 @@ from .sketch_solver import resolve_required_sketches
|
||||
|
||||
P3_ATOMIC_IDS = frozenset({
|
||||
"reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided",
|
||||
"extrude_cut_blind", "revolve_add", "revolve_cut",
|
||||
"extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
|
||||
})
|
||||
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
|
||||
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
|
||||
@@ -27,7 +27,7 @@ P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
|
||||
# revolve history. Whether a first cut has a preceding active body remains a
|
||||
# runtime preflight question, not a reason to erase it from the input pool.
|
||||
_P3_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut",
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
|
||||
})
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ def _p3_profile_ready(cdsl: dict[str, Any]) -> bool:
|
||||
if profile.get("type") not in P3_PROFILE_TYPES:
|
||||
return False
|
||||
if not (
|
||||
any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
|
||||
or len(sketch.get("contour_edges_mm") or ()) >= 2
|
||||
any(len(region.get("outer") or []) >= 1 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
|
||||
or len(sketch.get("contour_edges_mm") or ()) >= 1
|
||||
or any(
|
||||
entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0
|
||||
for entity in sketch.get("entities") or ()
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
{
|
||||
"schema": "cdsl.engine.schema.v1",
|
||||
"schema_version": "1.3.1",
|
||||
"schema_version": "1.3.3",
|
||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"],
|
||||
"operation_contracts": {
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
|
||||
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
|
||||
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
|
||||
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]},
|
||||
"sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
|
||||
"box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
|
||||
"cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
|
||||
"thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]},
|
||||
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
|
||||
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]},
|
||||
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[]},
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_surface": {"atomic_id":"loft_surface","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":2,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_wizard": {
|
||||
"runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "hole_wizard",
|
||||
"contract_version": "3.0",
|
||||
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "required"},
|
||||
@@ -52,11 +68,56 @@
|
||||
"semantic_preflight": ["host_face_exists", "hole_positions_on_host_plane"],
|
||||
"candidate_verifiers": ["cylindrical_bore"]
|
||||
},
|
||||
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
|
||||
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]},
|
||||
"pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]},
|
||||
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]},
|
||||
"pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}
|
||||
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","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},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"},"targetless_body_set":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"transform_bodies": {
|
||||
"runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "transform_bodies",
|
||||
"contract_version": "3.4",
|
||||
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"},
|
||||
"author_params_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["translation", "rotation", "uniform_scale"]},
|
||||
"translation_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"axis": {"type": "object", "properties": {"origin_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, "direction": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}, "required": ["origin_mm", "direction"], "additionalProperties": false},
|
||||
"angle_deg": {"type": "number"},
|
||||
"center_mm": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"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
|
||||
},
|
||||
"make_copy": {"type": "boolean"}
|
||||
},
|
||||
"required": ["transform", "make_copy"],
|
||||
"anyOf": [{"required": ["source_feature_ids"]}, {"required": ["pattern_instance_refs"]}, {"required": ["transform_copy_refs"]}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selector_policy": {"slot": null, "token_kind": null, "min_items": 0, "max_items": 0, "snapshot_bound": false},
|
||||
"server_injected_paths": [],
|
||||
"reference_policy": {"mode": "snapshot_bound", "slot": "params.source_feature_ids", "token_kind": "feature", "min_items": 1, "max_items": 16, "snapshot_bound": true},
|
||||
"semantic_preflight": ["source_bodies_exist", "pattern_instance_bodies_exist", "transform_copy_bodies_exist", "body_transform"],
|
||||
"candidate_verifiers": []
|
||||
},
|
||||
"delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}
|
||||
},
|
||||
"profiles": {
|
||||
"circle": {
|
||||
@@ -71,7 +132,13 @@
|
||||
"constraints": ["vertices contains at least three [u, v] points"]
|
||||
},
|
||||
"analytic_contours": {
|
||||
"summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry."
|
||||
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
|
||||
},
|
||||
"multi_source_regions": {
|
||||
"summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor."
|
||||
},
|
||||
"planar_imprint": {
|
||||
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ try:
|
||||
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 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,
|
||||
@@ -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", "loft_surface", "sweep_add", "sweep_cut",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
"reference_plane", "reference_axis", "reference_point", "assign_variable",
|
||||
"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
|
||||
+199
-1391
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"_meta": {
|
||||
"title": "CDSL 语义词表几何签名(初稿 v0.1)",
|
||||
"protocol": "cad.cdsl.llm.v1",
|
||||
"date": "2026-09-14",
|
||||
"role": "三层关联机制的字典层:label ↔ 几何期望的机器可读绑定。签名是期望(expectation),不是模板(template)。",
|
||||
"checking_semantics": "签名检查只产生警告与训练数据分级,永不阻塞建模与重建(与 AGENTS.md 一致:不把语义分歧当程序错误)。",
|
||||
"input_dependency": "metric_expectations 依赖重建管线输出的逐特征指标(derived_metrics per_feature delta)。缺失 delta 时,退化检查为:metric_expectations 中 *_delta 键跳过,其余照查。",
|
||||
"inheritance": "label 可带 parent,检查时先应用父签名再应用自身(并集)。composite=true 表示该语义典型地由特征组合实现(如打孔+阵列),检查时沿 depends_on 链聚合 delta。",
|
||||
"out_of_vocabulary": "词表外 label 无签名,程序侧零校验(开放词表的设计使然);训练侧应引导收敛到词表内。",
|
||||
"known_limitation": "纯用途差异在几何上不可分:coolant_channel 与 lubrication_gallery、cosmetic_surface 与 stress_relief_fillet 的几何签名几乎相同,其区分依赖 intent.why 与上下文,不依赖本签名。",
|
||||
"cross_field_reference": "param_expectations 的值可以引用另一参数路径(如 {\">\": \"diameter_mm\"}),匹配器解析为跨字段比较。",
|
||||
"signature_version": "0.1",
|
||||
"vocabulary_count": 37
|
||||
},
|
||||
"signatures": {
|
||||
"bolt_circle": {
|
||||
"parent": "fastener_hole",
|
||||
"composite": true,
|
||||
"allowed_atomics": ["pattern_circular", "pattern_linear", "hole_wizard", "hole_blind"],
|
||||
"param_expectations": {
|
||||
"pattern_count": {">=": 3},
|
||||
"positions": {">=": 3}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 3},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"relations": ["instances_same_diameter", "instances_on_common_circle"],
|
||||
"notes_zh": "一组等径紧固孔沿公共圆周分布。典型实现:hole_wizard(1孔)+pattern_circular 阵列,或一次多 positions。检查时沿 depends_on 聚合打孔与阵列两特征的 delta。"
|
||||
},
|
||||
"fastener_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {
|
||||
"end_condition.type": {"in": ["through_all", "through_all_both", "blind"]}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "紧固件过孔(光孔)。词表父节点:tap_hole / bolt_circle / 沉头类均为其子型或伴生型。"
|
||||
},
|
||||
"tap_hole": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_wizard", "thread_cut"],
|
||||
"param_expectations": {
|
||||
"thread": {"required_if_atomic": "hole_wizard"}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true,
|
||||
"creates_thread_helix": false
|
||||
},
|
||||
"notes_zh": "螺纹孔。注意:hole_wizard 的 thread 装饰当前被引擎降级为光孔(thread_decoration_ignored 诊断),creates_thread_helix 恒为 false,签名如实反映现状;thread_cut 才产生真实螺旋几何。"
|
||||
},
|
||||
"counterbore_seat": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_counterbore", "hole_wizard"],
|
||||
"param_expectations": {
|
||||
"counterbore_diameter_mm": {">": "diameter_mm"},
|
||||
"counterbore_depth_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true,
|
||||
"creates_planar_shoulder": true
|
||||
},
|
||||
"notes_zh": "沉头柱坑:主孔 + 同轴大直径浅坑,坑底形成环形平面肩。"
|
||||
},
|
||||
"countersink_seat": {
|
||||
"parent": "fastener_hole",
|
||||
"allowed_atomics": ["hole_countersink", "hole_wizard"],
|
||||
"param_expectations": {
|
||||
"countersink_diameter_mm": {">": "diameter_mm"},
|
||||
"countersink_angle_rad": {">": 0, "<": 3.141592653589793}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cone_face": true
|
||||
},
|
||||
"notes_zh": "沉头锥坑:主孔 + 锥形扩口,锥面是与柱面区分的强拓扑指纹。"
|
||||
},
|
||||
"locating_pin_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "定位销孔。配合公差与双孔位置度是设计要点,但公差不在 CDSL 当前表达能力内,机器只能查到 存在性/数量;配合语义写 why。"
|
||||
},
|
||||
"bearing_seat": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add", "revolve_add", "hole_wizard"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {
|
||||
"creates_cylindrical_fit_surface": true
|
||||
},
|
||||
"notes_zh": "轴承位(轴颈或座孔)。功能型语义,几何签名天然宽:内核是圆柱配合面(外圆或内孔),方向(增/减材)与安装形式有关。尺寸配合关系写 why。"
|
||||
},
|
||||
"shaft_passage": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {
|
||||
"end_condition.type": {"in": ["through_all", "through_all_both"]}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"relations": ["axis_aligned_with_body_center_or_axis"],
|
||||
"notes_zh": "过轴通孔。贯穿是强约束(签名可查);同心是典型但非必然(签名标注为关系提示)。"
|
||||
},
|
||||
"press_fit_boss": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_outer_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "压配合凸台:增材圆柱特征。过盈量等配合信息写在 why,几何只可查 增材+圆柱面。"
|
||||
},
|
||||
"alignment_datum": {
|
||||
"allowed_atomics": ["reference_plane", "reference_axis", "extrude_add_blind", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">=": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "对中/对位基准结构。可以是参考几何(零材料变化)也可以是小凸台/销孔;签名宽,依赖 why。"
|
||||
},
|
||||
"mounting_boss": {
|
||||
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_outer_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "安装凸台:增材圆柱特征,常带后续紧固孔(组合语义)。"
|
||||
},
|
||||
"mounting_foot": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "安装底脚:增材板状特征,形成安装平面。"
|
||||
},
|
||||
"lifting_eye": {
|
||||
"allowed_atomics": ["extrude_add_blind", "revolve_add", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "吊环/吊耳:增材结构带贯穿吊孔(吊索穿过)。孔的存在是较强指纹。"
|
||||
},
|
||||
"slot_adjustment": {
|
||||
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "hole_wizard", "hole_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_planar_walls": true
|
||||
},
|
||||
"notes_zh": "调整长孔/滑槽:允许位置调节的细长切口。典型实现:长圆 polygon 切除,或两孔+直切。"
|
||||
},
|
||||
"coolant_channel": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "冷却通道。与 lubrication_gallery 几何签名几乎相同(能力边界案例),区分靠 why 与介质上下文。"
|
||||
},
|
||||
"lubrication_gallery": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "润滑油路。同上,几何上与冷却通道不可分。"
|
||||
},
|
||||
"vent_hole": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"hole_count_delta": {">=": 1},
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "排气孔:小直径贯穿孔,通常贯穿(呼吸/排气的功能要求)。"
|
||||
},
|
||||
"drain_port": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "排液口。位置在最低点是设计要点但不在几何签名能力内,写 why。"
|
||||
},
|
||||
"fluid_inlet": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "进液口。可能是孔(减材)也可能是接管凸台+孔(增减组合),签名宽。"
|
||||
},
|
||||
"process_corner_relief": {
|
||||
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "chamfer", "fillet"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "工艺让位(避让刀具/相邻件干涉)。材料切除量小是典型特征。"
|
||||
},
|
||||
"weld_prep": {
|
||||
"allowed_atomics": ["chamfer"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_cone_face": true
|
||||
},
|
||||
"notes_zh": "焊接坡口:倒角原子直接映射(坡口即倒角),锥面是强指纹。"
|
||||
},
|
||||
"machining_setup_tab": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "装夹工艺台:临时增材,后续工序去除。生命周期语义写在 why(几何本身与普通小凸台不可分)。"
|
||||
},
|
||||
"inspection_access": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_inner_cylindrical_face": true
|
||||
},
|
||||
"notes_zh": "测量/探针可达孔。"
|
||||
},
|
||||
"stress_relief_fillet": {
|
||||
"allowed_atomics": ["fillet"],
|
||||
"param_expectations": {
|
||||
"radius_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0},
|
||||
"face_count_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_torus_face": true
|
||||
},
|
||||
"notes_zh": "应力缓解圆角。torus 面(圆角面)+面数增加是修饰操作生效的强指纹;与 cosmetic_surface 的区别是用途而非几何。"
|
||||
},
|
||||
"stiffening_rib": {
|
||||
"allowed_atomics": ["extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "加强筋:增材薄壁特征。薄(相对壁厚)是定义的一部分但属跨字段相对约束,初稿不做机器检查。"
|
||||
},
|
||||
"weight_relief": {
|
||||
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_blind", "extrude_cut_through"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "减重(孔/槽)。通常多孔或多腔(volume delta 显著为负)。"
|
||||
},
|
||||
"mass_saving_pocket": {
|
||||
"allowed_atomics": ["extrude_cut_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_planar_faces": true
|
||||
},
|
||||
"notes_zh": "减重腔:盲切腔体,不留穿。与 weight_relief 的区别是形式(腔 vs 孔阵)。"
|
||||
},
|
||||
"load_path_flange": {
|
||||
"allowed_atomics": ["extrude_add_blind", "revolve_add"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "承载法兰/接盘。功能型语义,签名宽。"
|
||||
},
|
||||
"wall_thickness_transition": {
|
||||
"allowed_atomics": ["shell", "extrude_add_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "壁厚过渡。实现多样(抽壳/变截面拉伸),签名宽。"
|
||||
},
|
||||
"gear_teeth": {
|
||||
"allowed_atomics": ["gear_add"],
|
||||
"param_expectations": {
|
||||
"module_mm": {">": 0},
|
||||
"teeth_count": {">=": 8}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_involute_or_bspline_faces": true
|
||||
},
|
||||
"notes_zh": "轮齿:gear_add 原子专属。渐开线/自由曲面齿面是强指纹(亦可用于检测 gear_add 是否真的生成几何)。"
|
||||
},
|
||||
"rack_teeth": {
|
||||
"allowed_atomics": ["rack_add"],
|
||||
"param_expectations": {
|
||||
"module_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_involute_or_bspline_faces": true
|
||||
},
|
||||
"notes_zh": "齿条齿:rack_add 原子专属。"
|
||||
},
|
||||
"thread_drive": {
|
||||
"allowed_atomics": ["thread_add"],
|
||||
"param_expectations": {
|
||||
"pitch_mm": {">": 0}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_thread_helix": true
|
||||
},
|
||||
"notes_zh": "传动螺纹(外螺纹实体段):thread_add 专属,螺旋面是强指纹。"
|
||||
},
|
||||
"cam_track": {
|
||||
"allowed_atomics": ["sweep_add", "extrude_cut_through", "extrude_cut_blind"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {},
|
||||
"topology_expectations": {
|
||||
"creates_bspline_curve_geometry": true
|
||||
},
|
||||
"notes_zh": "凸轮轨道:曲线扫掠或曲线槽,B 样条路径/曲线是其指纹。"
|
||||
},
|
||||
"bend_wing": {
|
||||
"allowed_atomics": ["bend_add"],
|
||||
"param_expectations": {
|
||||
"chain": {">=": 1}
|
||||
},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {">": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_cylindrical_bend_face": true
|
||||
},
|
||||
"notes_zh": "折弯翼:bend_add 原子专属,折弯内/外圆柱面是指纹。"
|
||||
},
|
||||
"cosmetic_surface": {
|
||||
"allowed_atomics": ["fillet", "chamfer"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"<": 0}
|
||||
},
|
||||
"topology_expectations": {
|
||||
"creates_torus_face": true
|
||||
},
|
||||
"notes_zh": "外观修饰(倒圆/倒角)。与 stress_relief_fillet 几何签名几乎相同(能力边界案例),区别写 why。"
|
||||
},
|
||||
"datum_plane_feature": {
|
||||
"allowed_atomics": ["reference_plane"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"==": 0},
|
||||
"face_count_delta": {"==": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "设计基准面:零材料变化 + 零拓扑变化 + 特定原子,是全部签名中最确定的一条(强指纹)。"
|
||||
},
|
||||
"datum_axis_feature": {
|
||||
"allowed_atomics": ["reference_axis"],
|
||||
"param_expectations": {},
|
||||
"metric_expectations": {
|
||||
"volume_delta": {"==": 0},
|
||||
"face_count_delta": {"==": 0}
|
||||
},
|
||||
"topology_expectations": {},
|
||||
"notes_zh": "设计基准轴:同上,强指纹。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
"""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 .sketch_solver import resolve_profile
|
||||
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
|
||||
from .topology import (
|
||||
FeaturePlanNode,
|
||||
FeatureResult,
|
||||
RuntimeDiagnostic,
|
||||
SelectorResolution,
|
||||
TopologyDelta,
|
||||
TopologyRecord,
|
||||
TopologyRegistry,
|
||||
validate_selector_provenance_intent,
|
||||
)
|
||||
|
||||
|
||||
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], *, support_face: Any | None = None,
|
||||
external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ...
|
||||
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None,
|
||||
external_anchor_edges: dict[str, Any] | None = None) -> tuple[list[Any], list[dict[str, 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_surface(self, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
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 sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ...
|
||||
def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ...
|
||||
def face_normal(self, face: Any) -> Vector3: ...
|
||||
def planar_face_workplane(self, face: Any) -> PlaneSpec: ...
|
||||
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
||||
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
|
||||
def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ...
|
||||
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_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ...
|
||||
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 cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ...
|
||||
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 target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ...
|
||||
def uniform_planar_supporting_surface_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)
|
||||
body_member_snapshot_ids: dict[str, str] = field(default_factory=dict)
|
||||
surface_members: dict[str, Any] = field(default_factory=dict)
|
||||
sketch_attachment_faces: dict[str, Any] = field(default_factory=dict)
|
||||
sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
|
||||
active_feature_id: str = ""
|
||||
|
||||
def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None:
|
||||
"""Resolve one runtime-attached sketch immediately before consumption."""
|
||||
sketch = self.sketches.get(sketch_id)
|
||||
if sketch is None or not isinstance(sketch.get("attachment"), dict):
|
||||
return
|
||||
previous_feature_id = self.active_feature_id
|
||||
if feature_id is not None:
|
||||
self.active_feature_id = feature_id
|
||||
try:
|
||||
resolution = self.resolve(sketch["attachment"])
|
||||
if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face":
|
||||
detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved"
|
||||
raise FeatureExecutionError("sketch_attachment_unresolved", detail)
|
||||
try:
|
||||
plane = self.adapter.planar_face_workplane(resolution.record.value)
|
||||
except ValueError as error:
|
||||
raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error
|
||||
external_edges: dict[str, Any] = {}
|
||||
profile = sketch.get("profile") or {}
|
||||
for entry in profile.get("external_anchors") or ():
|
||||
anchor_id = entry.get("id") if isinstance(entry, dict) else None
|
||||
selector = entry.get("selector") if isinstance(entry, dict) else None
|
||||
if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict):
|
||||
raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid")
|
||||
edge_resolution = self.resolve(selector)
|
||||
if (
|
||||
edge_resolution.status != "resolved"
|
||||
or edge_resolution.record is None
|
||||
or edge_resolution.record.kind != "edge"
|
||||
):
|
||||
detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved"
|
||||
raise FeatureExecutionError("imprint_external_anchor_unresolved", detail)
|
||||
if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()):
|
||||
raise FeatureExecutionError(
|
||||
"imprint_external_anchor_not_support_boundary",
|
||||
"external anchor edge is not an exact boundary of the attached support face",
|
||||
)
|
||||
external_edges[anchor_id] = edge_resolution.record.value
|
||||
materialized = deepcopy(sketch)
|
||||
materialized["workplane"] = plane.as_dict()
|
||||
# IMPRINT face-side disambiguation is expressed in the source
|
||||
# sketch's oriented plane. A native attached face may have the
|
||||
# opposite normal, which reverses the physical side of the same
|
||||
# local curve after its coordinates are mapped to that face.
|
||||
# This is a session-local frame conversion, not selector or
|
||||
# geometry fallback data.
|
||||
source_profile = materialized.get("profile") or {}
|
||||
if source_profile.get("type") == "planar_imprint":
|
||||
try:
|
||||
source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
except ValueError as error:
|
||||
raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error
|
||||
orientation = sum(
|
||||
source_plane.normal[index] * plane.normal[index]
|
||||
for index in range(3)
|
||||
)
|
||||
if orientation < 0.0:
|
||||
for selection in source_profile.get("selections") or ():
|
||||
if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}:
|
||||
selection["face_side"] = -selection["face_side"]
|
||||
# Preflight intentionally leaves attached sketches local. Resolve now
|
||||
# so every world-space contour is rebuilt from the proven face frame.
|
||||
materialized = resolve_profile(materialized)
|
||||
self.sketches[sketch_id] = materialized
|
||||
# Keep exact supports only in the live session. They are never CDSL
|
||||
# geometry, serialized record IDs, or selector fallback evidence.
|
||||
self.sketch_attachment_faces[sketch_id] = resolution.record.value
|
||||
if external_edges:
|
||||
self.sketch_imprint_external_edges[sketch_id] = external_edges
|
||||
finally:
|
||||
self.active_feature_id = previous_feature_id
|
||||
|
||||
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,
|
||||
topology_anchors: list[TopologyRecord] | None = None,
|
||||
) -> None:
|
||||
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
||||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||||
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
|
||||
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
|
||||
previous_members = dict(self.body_members)
|
||||
previous_snapshot_ids = dict(self.body_member_snapshot_ids)
|
||||
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}
|
||||
# Source-profile anchors are transient construction facts, but unlike
|
||||
# generic role predecessors they must remain addressable by a later
|
||||
# selector intent. They never receive a body id, so active selector
|
||||
# scans cannot mistake them for current model topology.
|
||||
anchors = list(topology_anchors or ())
|
||||
for anchor in anchors:
|
||||
self.topology.register(anchor)
|
||||
predecessors = [*(topology_predecessors or ()), *anchors]
|
||||
solids = self.adapter.body_solids(body)
|
||||
member_snapshot_ids: dict[str, str] = {}
|
||||
for member_key, member in self.body_members.items():
|
||||
matches = [
|
||||
index for index, solid in enumerate(solids)
|
||||
if TopologyRegistry._same_topology_value(member, solid)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
member_snapshot_ids[member_key] = (
|
||||
self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}"
|
||||
)
|
||||
member_preservations = [
|
||||
(previous_snapshot_ids[key], member_snapshot_ids[key])
|
||||
for key, member in previous_members.items()
|
||||
if key in previous_snapshot_ids and key in member_snapshot_ids
|
||||
and TopologyRegistry._same_topology_value(member, self.body_members[key])
|
||||
]
|
||||
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=predecessors, member_preservations=member_preservations,
|
||||
)
|
||||
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=predecessors, member_preservations=member_preservations,
|
||||
)
|
||||
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
|
||||
self.body_member_snapshot_ids = member_snapshot_ids
|
||||
|
||||
def register_transient_prism_tool(
|
||||
self,
|
||||
feature_id: str,
|
||||
tool: Any,
|
||||
*,
|
||||
topology_delta: TopologyDelta,
|
||||
topology_anchors: list[TopologyRecord],
|
||||
) -> list[TopologyRecord]:
|
||||
"""Keep one direct-prism primary tool as boolean-input evidence only."""
|
||||
snapshot_id = f"transient:{feature_id}"
|
||||
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
|
||||
return list(self.topology.register_transient_snapshot(
|
||||
feature_id,
|
||||
snapshot_id,
|
||||
records,
|
||||
topology_delta=topology_delta,
|
||||
anchors=topology_anchors,
|
||||
))
|
||||
|
||||
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 = {}
|
||||
self.body_member_snapshot_ids = {}
|
||||
|
||||
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 and not item.records)]
|
||||
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 [
|
||||
record
|
||||
for item in resolved
|
||||
for record in (item.records or ((item.record,) if item.record is not None else ()))
|
||||
]
|
||||
|
||||
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:
|
||||
# The session computes vertex intersections directly from resolved
|
||||
# face components, so enforce the same outer provenance gate that
|
||||
# TopologyRegistry.resolve applies before any geometry operation.
|
||||
validation_error = validate_selector_provenance_intent(selector)
|
||||
if validation_error is not None:
|
||||
return self._record_selector_resolution(SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=validation_error,
|
||||
))
|
||||
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
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Core CDSL sketch resolver.
|
||||
|
||||
The runtime accepts only direct geometric descriptions: circles, straight-edge
|
||||
polygons, and closed analytic line/arc/circle contours. Semantic shapes and
|
||||
historical profile macros belong to the importer compatibility layer and must
|
||||
be lowered before this module is invoked.
|
||||
polygons, and closed analytic line/arc/circle/ellipse/B-spline contours. Semantic
|
||||
shapes and historical profile macros belong to the importer compatibility
|
||||
layer and must be lowered before this module is invoked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
|
||||
_TOLERANCE_MM = 1e-5
|
||||
|
||||
|
||||
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
|
||||
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
|
||||
def _circle(
|
||||
center: list[float],
|
||||
radius_mm: float,
|
||||
construction: bool = False,
|
||||
source_entity_id: str | None = None,
|
||||
) -> _Ctx:
|
||||
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
|
||||
if source_entity_id is not None:
|
||||
output["source_entity_id"] = source_entity_id
|
||||
return output
|
||||
|
||||
|
||||
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
|
||||
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
|
||||
def _line(
|
||||
start: list[float],
|
||||
end: list[float],
|
||||
construction: bool = False,
|
||||
source_entity_id: str | None = None,
|
||||
) -> _Ctx:
|
||||
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
|
||||
if source_entity_id is not None:
|
||||
output["source_entity_id"] = source_entity_id
|
||||
return output
|
||||
|
||||
|
||||
def _point(point: list[float]) -> list[float]:
|
||||
@@ -58,6 +74,12 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
|
||||
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
|
||||
|
||||
|
||||
def _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]:
|
||||
origin = _to_3d(workplane, 0.0, 0.0)
|
||||
target = _to_3d(workplane, u, v)
|
||||
return [target[index] - origin[index] for index in range(3)]
|
||||
|
||||
|
||||
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
|
||||
return sum(a * b for a, b in zip(left, right))
|
||||
|
||||
@@ -76,11 +98,30 @@ 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":
|
||||
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
|
||||
output["normal"] = list(normal)
|
||||
elif edge["type"] == "ellipse":
|
||||
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
|
||||
output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_mm"][1])
|
||||
output["normal"] = list(normal)
|
||||
elif edge["type"] == "bspline":
|
||||
output["points_mm"] = [
|
||||
_to_3d(workplane, point[0], point[1])
|
||||
for point in edge["points_mm"]
|
||||
]
|
||||
for key in ("start_tangent_mm", "end_tangent_mm"):
|
||||
if key in edge:
|
||||
tangent = edge[key]
|
||||
output[key] = _to_3d_vector(workplane, tangent[0], tangent[1])
|
||||
transformed.append(output)
|
||||
return transformed
|
||||
|
||||
@@ -91,8 +132,10 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
if radius <= 0:
|
||||
raise ValueError("circle radius must be > 0")
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]]
|
||||
return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)]
|
||||
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
|
||||
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
|
||||
source_entity_id = profile.get("source_entity_id")
|
||||
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
|
||||
|
||||
|
||||
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
@@ -113,11 +156,36 @@ def _distance(left: list[float], right: list[float]) -> float:
|
||||
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
|
||||
|
||||
|
||||
def _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]:
|
||||
pairs = list(zip(points, points[1:]))
|
||||
if periodic:
|
||||
pairs.append((points[-1], points[0]))
|
||||
parameters = [0.0]
|
||||
for start, end in pairs:
|
||||
distance = math.dist(start, end)
|
||||
if distance <= _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points")
|
||||
parameters.append(parameters[-1] + math.sqrt(distance))
|
||||
return parameters
|
||||
|
||||
|
||||
def _reverse(edge: _Ctx) -> _Ctx:
|
||||
output = deepcopy(edge)
|
||||
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
|
||||
if output.get("type") == "arc" and "clockwise" in output:
|
||||
output["clockwise"] = not bool(output["clockwise"])
|
||||
if output.get("type") == "bspline":
|
||||
output["points_mm"] = list(reversed(output["points_mm"]))
|
||||
parameters = output.get("parameters")
|
||||
if parameters is not None:
|
||||
final_parameter = float(parameters[-1])
|
||||
output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)]
|
||||
start_tangent = output.pop("start_tangent_mm", None)
|
||||
end_tangent = output.pop("end_tangent_mm", None)
|
||||
if end_tangent is not None:
|
||||
output["start_tangent_mm"] = [-float(value) for value in end_tangent]
|
||||
if start_tangent is not None:
|
||||
output["end_tangent_mm"] = [-float(value) for value in start_tangent]
|
||||
return output
|
||||
|
||||
|
||||
@@ -164,20 +232,141 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
clockwise = bool(segment.get("clockwise", False))
|
||||
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
|
||||
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
|
||||
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
|
||||
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
if isinstance(source_entity_id, str) and source_entity_id:
|
||||
# Region construction needs four arc segments, but this marker records
|
||||
# that all four came from exactly one logical source circle. It is not
|
||||
# an edge anchor: callers must rebuild one native circle wire and pass
|
||||
# final-face identity checks before using it for lineage.
|
||||
for edge in edges:
|
||||
edge["logical_circle_source_entity_id"] = source_entity_id
|
||||
return edges
|
||||
|
||||
|
||||
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
center = segment.get("center") or [0.0, 0.0]
|
||||
major_radius = float(segment.get("major_radius_mm") or 0.0)
|
||||
minor_radius = float(segment.get("minor_radius_mm") or 0.0)
|
||||
major_axis = segment.get("major_axis") or []
|
||||
if major_radius <= 0 or minor_radius <= 0:
|
||||
raise ValueError("analytic_contours: ellipse radii must be > 0")
|
||||
if len(major_axis) < 2:
|
||||
raise ValueError("analytic_contours: ellipse major_axis must have two components")
|
||||
axis_length = math.hypot(float(major_axis[0]), float(major_axis[1]))
|
||||
if axis_length <= _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: ellipse major_axis is degenerate")
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length
|
||||
start = [cx + major_radius * ux, cy + major_radius * uy, 0.0]
|
||||
return [{
|
||||
"type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0],
|
||||
"major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius,
|
||||
}]
|
||||
|
||||
|
||||
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
kind = segment.get("type")
|
||||
if kind == "line":
|
||||
return [_contour_line(segment["start"], segment["end"])]
|
||||
if kind == "arc":
|
||||
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
||||
if kind == "circle":
|
||||
return _circle_edges(segment)
|
||||
if kind == "bspline":
|
||||
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
|
||||
edges = [_contour_line(segment["start"], segment["end"])]
|
||||
elif kind == "arc":
|
||||
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
||||
elif kind == "circle":
|
||||
edges = _circle_edges(segment)
|
||||
elif kind == "ellipse":
|
||||
edges = _ellipse_edges(segment)
|
||||
elif kind == "bspline":
|
||||
points = segment.get("points") or []
|
||||
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")
|
||||
# The duplicated closing interpolation point describes topology,
|
||||
# not an additional periodic interpolation constraint. OCC's
|
||||
# periodic interpolator receives each unique point exactly once.
|
||||
interpolation_points = converted[:-1]
|
||||
else:
|
||||
interpolation_points = converted
|
||||
parameterization = segment.get("parameterization")
|
||||
if parameterization not in {None, "chord", "centripetal"}:
|
||||
raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}")
|
||||
parameters = segment.get("parameters")
|
||||
if parameters is not None:
|
||||
expected_count = len(interpolation_points) + int(periodic)
|
||||
if len(parameters) != expected_count:
|
||||
raise ValueError("analytic_contours: bspline parameter count does not match interpolation points")
|
||||
parameters = [float(value) for value in parameters]
|
||||
if not all(math.isfinite(value) for value in parameters):
|
||||
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],
|
||||
"end_mm": converted[-1],
|
||||
"points_mm": interpolation_points,
|
||||
"periodic": periodic,
|
||||
**({"parameters": parameters} if parameters is not None else {}),
|
||||
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
|
||||
}
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
|
||||
if start_tangent is not None:
|
||||
if periodic:
|
||||
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
|
||||
output["start_tangent_mm"] = _point(start_tangent)
|
||||
output["end_tangent_mm"] = _point(end_tangent)
|
||||
edges = [output]
|
||||
else:
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
# Only a one-edge construction has the direct, one-to-one source identity
|
||||
# required by profile-to-prism lineage. Circles expanded to arcs carry a
|
||||
# separate logical-circle marker, not a source edge identity; the adapter
|
||||
# may use it only to reconstruct one native circle wire with exact final
|
||||
# membership proof. Other multi-edge approximations remain unanchored.
|
||||
if isinstance(source_entity_id, str) and len(edges) == 1:
|
||||
edges[0]["source_entity_id"] = source_entity_id
|
||||
return edges
|
||||
|
||||
|
||||
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]]:
|
||||
@@ -185,6 +374,58 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
|
||||
for edge in edges:
|
||||
start = edge["start_mm"]
|
||||
points.append((float(start[0]), float(start[1])))
|
||||
if edge.get("type") == "bspline":
|
||||
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"]
|
||||
major_radius = float(edge["major_radius_mm"])
|
||||
minor_radius = float(edge["minor_radius_mm"])
|
||||
normal = edge.get("normal") or [0.0, 0.0, 1.0]
|
||||
axis_length = math.sqrt(sum(float(value) * float(value) for value in axis))
|
||||
normal_length = math.sqrt(sum(float(value) * float(value) for value in normal))
|
||||
if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: ellipse axis is degenerate")
|
||||
x_axis = [float(value) / axis_length for value in axis]
|
||||
z_axis = [float(value) / normal_length for value in normal]
|
||||
y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]]
|
||||
for step in range(1, 8):
|
||||
angle = math.tau * step / 8
|
||||
points.append((
|
||||
float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0],
|
||||
float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[1],
|
||||
))
|
||||
continue
|
||||
if edge.get("type") != "arc":
|
||||
continue
|
||||
center, end = edge["center_mm"], edge["end_mm"]
|
||||
@@ -248,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo
|
||||
return inside
|
||||
|
||||
|
||||
def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
|
||||
"""Return whether a sampled contour point lies on one sampled boundary.
|
||||
|
||||
Region nesting is topology, not a ray-casting tie-break. A contour that
|
||||
shares a vertex or an edge with another contour cannot be a hole of it.
|
||||
The ordinary parity test intentionally leaves that boundary case
|
||||
unspecified, so keep it out of containment classification explicitly.
|
||||
"""
|
||||
if len(loop) < 2:
|
||||
return False
|
||||
px, py = point
|
||||
previous = loop[-1]
|
||||
for current in loop:
|
||||
dx, dy = current[0] - previous[0], current[1] - previous[1]
|
||||
length_squared = dx * dx + dy * dy
|
||||
if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM:
|
||||
if math.dist(point, previous) <= _TOLERANCE_MM:
|
||||
return True
|
||||
else:
|
||||
projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared
|
||||
if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM:
|
||||
nearest = (previous[0] + projection * dx, previous[1] + projection * dy)
|
||||
if math.dist(point, nearest) <= _TOLERANCE_MM:
|
||||
return True
|
||||
previous = current
|
||||
return False
|
||||
|
||||
|
||||
def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool:
|
||||
"""Require every sampled inner boundary point to be strictly inside outer.
|
||||
|
||||
This conservative predicate rejects touching and intersecting contours
|
||||
rather than manufacturing an invalid face with a self-identical or shared
|
||||
hole. Curved contours are already sampled by ``_sample_loop`` before
|
||||
this stage, so the decision uses the same region representation as the
|
||||
existing parity classifier.
|
||||
"""
|
||||
return bool(inner) and all(
|
||||
not _point_on_loop_boundary(point, outer) and _contains(point, outer)
|
||||
for point in inner
|
||||
)
|
||||
|
||||
|
||||
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
loops: list[_Ctx] = []
|
||||
entities: list[_Ctx] = []
|
||||
@@ -261,13 +545,30 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
raw_edges: list[_Ctx] = []
|
||||
for segment in contour.get("segments") or []:
|
||||
if segment.get("type") == "line":
|
||||
entities.append(_line(segment["start"], segment["end"]))
|
||||
entities.append(_line(
|
||||
segment["start"], segment["end"],
|
||||
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
|
||||
))
|
||||
elif segment.get("type") == "circle":
|
||||
if contour_open:
|
||||
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
|
||||
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
|
||||
entities.append(_circle(
|
||||
segment.get("center") or [0.0, 0.0],
|
||||
float(segment.get("radius_mm") or 0.0),
|
||||
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
|
||||
))
|
||||
raw_edges.extend(_segment_edges(segment))
|
||||
if raw_edges:
|
||||
# A pure surface extrusion consumes its source wire directly.
|
||||
# Do not invent the closing edge used by the solid-cut open
|
||||
# profile contract: it would turn one requested ruled face into
|
||||
# a different shell. This marker is emitted only by the CADFS
|
||||
# ToolBodyType.SURFACE lowering contract.
|
||||
if bool(contour.get("surface_wire")):
|
||||
if not contour_open or contour.get("role") != "open":
|
||||
raise ValueError(f"analytic_contours: surface wire {index} must be an open contour")
|
||||
meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True))
|
||||
continue
|
||||
edges = _join(raw_edges, allow_open=contour_open)
|
||||
contour_opened = False
|
||||
if contour_open:
|
||||
@@ -289,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
if not loops:
|
||||
return entities, []
|
||||
for loop in loops:
|
||||
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer"
|
||||
loop["role"] = "inner" if sum(
|
||||
_strictly_contains_loop(other["points"], loop["points"])
|
||||
for other in loops if other is not loop
|
||||
) % 2 else "outer"
|
||||
outers = [loop for loop in loops if loop["role"] == "outer"]
|
||||
regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers]
|
||||
for inner in (loop for loop in loops if loop["role"] == "inner"):
|
||||
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])]
|
||||
containing = [
|
||||
outer for outer in outers
|
||||
if _strictly_contains_loop(outer["points"], inner["points"])
|
||||
]
|
||||
if not containing:
|
||||
raise ValueError("analytic_contours: inner contour has no containing outer contour")
|
||||
if any(outer.get("open") for outer in containing):
|
||||
@@ -304,12 +611,112 @@ 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 _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx:
|
||||
"""Use the established region classifier once per source profile."""
|
||||
kind = profile.get("type")
|
||||
if kind == "analytic_contours":
|
||||
return deepcopy(profile)
|
||||
if kind == "circle":
|
||||
return {
|
||||
"type": "analytic_contours",
|
||||
"contours": [{"role": "outer", "closed": True, "segments": [{
|
||||
"type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]),
|
||||
"radius_mm": profile.get("radius_mm"),
|
||||
}]}],
|
||||
}
|
||||
if kind == "polygon":
|
||||
vertices = profile.get("vertices") or []
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("multi_source_regions: polygon needs at least three vertices")
|
||||
return {
|
||||
"type": "analytic_contours",
|
||||
"contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])}
|
||||
for index in range(len(vertices))
|
||||
]}],
|
||||
}
|
||||
raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}")
|
||||
|
||||
|
||||
def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
"""Resolve each qSketchRegion source independently before taking its union.
|
||||
|
||||
Flattening all contours would reclassify a nested region from a separate
|
||||
sketch as a hole. FeatureScript qUnion selects each source region, so its
|
||||
operands must retain independent outer/hole classification here.
|
||||
"""
|
||||
sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or []
|
||||
if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources):
|
||||
raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles")
|
||||
entities: list[_Ctx] = []
|
||||
regions: list[_Ctx] = []
|
||||
for child in profiles:
|
||||
if not isinstance(child, dict):
|
||||
raise ValueError("multi_source_regions: child profile must be an object")
|
||||
child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []}
|
||||
child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta)
|
||||
if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]:
|
||||
raise ValueError("multi_source_regions: every child must resolve to closed regions")
|
||||
entities.extend(child_entities)
|
||||
regions.extend(child_meta["_regions"])
|
||||
meta["_regions"] = regions
|
||||
return entities, []
|
||||
|
||||
|
||||
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})
|
||||
external_anchors = profile.get("external_anchors") or []
|
||||
external_ids = {
|
||||
str(entry.get("id") or "")
|
||||
for entry in external_anchors
|
||||
if isinstance(entry, dict)
|
||||
}
|
||||
single_external_fragment = (
|
||||
len(source_entities) == 1
|
||||
and bool(external_ids)
|
||||
and all(
|
||||
isinstance(selection, dict)
|
||||
and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids
|
||||
for selection in profile.get("selections") or ()
|
||||
)
|
||||
)
|
||||
if len(source_entities) < 2 and not single_external_fragment:
|
||||
raise ValueError("planar_imprint: at least two source entities or one external anchor 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,
|
||||
"multi_source_regions": _gen_multi_source_regions,
|
||||
"planar_imprint": _gen_planar_imprint,
|
||||
}
|
||||
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
|
||||
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
|
||||
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
|
||||
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
|
||||
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"},
|
||||
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
|
||||
"multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"},
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +735,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": [], "_surface_wires": [],
|
||||
}
|
||||
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")]
|
||||
@@ -345,6 +755,20 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
|
||||
]
|
||||
if meta["_has_open_contour"]:
|
||||
output["_open_contour"] = True
|
||||
if meta["_surface_wires"]:
|
||||
output["surface_wires_mm"] = [
|
||||
_transform_contours(wire, workplane) if workplane else wire
|
||||
for wire in meta["_surface_wires"]
|
||||
]
|
||||
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,351 @@
|
||||
"""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 OCP.BRep import BRep_Tool
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
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())
|
||||
|
||||
# build123d may wrap a vertex extracted from a solid in a distinct OCC
|
||||
# handle. Builder FirstShape/LastShape vertex history is only comparable
|
||||
# with the final result's native explorer handles, so retain those exact
|
||||
# handles for selector records. Faces and edges keep their established
|
||||
# build123d export path.
|
||||
result_vertices: list[Any] = []
|
||||
explorer = TopExp_Explorer(body.wrapped, TopAbs_VERTEX)
|
||||
while explorer.More():
|
||||
candidate = explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in result_vertices):
|
||||
result_vertices.append(candidate)
|
||||
explorer.Next()
|
||||
result_edges: list[Any] = []
|
||||
explorer = TopExp_Explorer(body.wrapped, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
candidate = explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in result_edges):
|
||||
result_edges.append(candidate)
|
||||
explorer.Next()
|
||||
|
||||
def exact_incident_edge_count(vertex: Any) -> int:
|
||||
count = 0
|
||||
for edge in result_edges:
|
||||
edge_vertices: list[Any] = []
|
||||
edge_explorer = TopExp_Explorer(edge, TopAbs_VERTEX)
|
||||
while edge_explorer.More():
|
||||
candidate = edge_explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in edge_vertices):
|
||||
edge_vertices.append(candidate)
|
||||
edge_explorer.Next()
|
||||
if any(vertex.IsSame(candidate) for candidate in edge_vertices):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
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(result_vertices):
|
||||
point_value = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex))
|
||||
point = [point_value.X(), point_value.Y(), point_value.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": exact_incident_edge_count(vertex)},
|
||||
))
|
||||
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",
|
||||
"",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user