From 0b95ad076533f98b9bb7517df1fc94510ee37bdd Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Sun, 1 Nov 2026 01:18:39 +0800 Subject: [PATCH 001/131] skill: a router with read sets, unattended as a mode, pages that load in one batch The skill entry is rebuilt around how an agent reads: SKILL.md is a 94-line router whose route table gives the batch to read for each workflow; the first batch is 488-881 compiled lines (was a median of 1,493). Long workflows are split into role pages (single-part 1157 -> outline + role pages, drawing-reconstruction 586 -> outline + stage pages); ql-playbook and notebook-runtime are split by job. Every workflow has Read now / Read when / Unattended mode, and discipline/operating-modes.md says what replaces each gate when nobody can answer. New: scan-reconstruction workflow, core-rules.md (rule IDs), parallel-read blocks for claudecode, zcode, opencode and codex. docs/skill-design.md is the contract; skillbuild --check enforces it (src/simplecadapi/skill/lint.py, opt in through [lint] in skillproj.toml). Co-Authored-By: Claude Sonnet 5.5 --- docs/skill-design.md | 136 ++ docs/skill/SKILL.md | 297 ++--- docs/skill/references/README.md | 165 +-- docs/skill/references/SDK_OVERVIEW.md | 21 +- .../discipline/assembly-positioning.md | 2 + .../skill/references/discipline/core-rules.md | 80 ++ .../datums-and-coordinate-systems.md | 3 + .../discipline/failure-and-repair.md | 15 +- .../references/discipline/feature-ordering.md | 2 + .../discipline/feature-tree-convention.md | 45 +- .../discipline/geometric-validation.md | 4 + .../discipline/manufacturing-boundaries.md | 3 + .../discipline/mechanical-modeling.md | 2 + .../discipline/notebook-cell-design.md | 21 +- .../references/discipline/operating-modes.md | 68 + .../discipline/requirement-and-cad-brief.md | 9 + .../references/discipline/verifier-scripts.md | 3 + docs/skill/references/docs/guides/README.md | 10 +- .../guides/notebook-composition-and-cache.md | 141 +++ .../docs/guides/notebook-runtime.md | 226 +--- .../references/docs/guides/product-export.md | 88 ++ .../references/domains/addon-development.md | 2 + .../domains/assembly-and-product.md | 2 + .../domains/drawing-inspection-evidence.md | 133 ++ .../references/domains/drawing-inspection.md | 130 +- .../domains/editor-collaboration.md | 14 +- .../references/domains/engineering-drawing.md | 2 + .../domains/export-and-translation.md | 2 + .../skill/references/domains/part-modeling.md | 3 + .../domains/requirement-refinement.md | 12 + .../references/domains/sketch-and-features.md | 3 + .../references/domains/standard-parts.md | 2 + .../references/domains/step-inspection.md | 2 + docs/skill/references/ql-playbook.md | 137 +-- docs/skill/references/ql-recipes.md | 142 +++ .../workflows/assembly-product-build.md | 48 + .../workflows/drawing-reconstruction.md | 520 ++------ .../role-1-drawing-confirmer.md | 107 ++ .../role-2-master-planner.md | 72 ++ .../role-3-verifier-planner.md | 113 ++ .../role-4-detail-builder.md | 70 ++ .../drawing-reconstruction/role-5-exporter.md | 22 + .../roundtrip-acceptance.md | 118 ++ .../workflows/engineering-drawing.md | 47 + .../workflows/export-and-translation.md | 35 + .../workflows/scan-reconstruction.md | 191 +++ .../workflows/single-part-modeling.md | 1089 +---------------- .../requirements-template.md | 24 + .../role-1-ask-bindings.md | 262 ++++ .../role-1-requirements.md | 49 + .../single-part-modeling/role-2-plan.md | 132 ++ .../role-3-host-bindings.md | 278 +++++ .../single-part-modeling/role-3-verifier.md | 49 + .../single-part-modeling/role-4-build.md | 158 +++ .../verifier-contract-patterns.md | 153 +++ .../workflows/step-reconstruction.md | 42 + skillproj.toml | 3 + src/simplecadapi/skill/compiler.py | 23 +- src/simplecadapi/skill/lint.py | 235 ++++ test/test_drawing_skill_integration.py | 8 +- test/test_skill_lint.py | 145 +++ 61 files changed, 3612 insertions(+), 2308 deletions(-) create mode 100644 docs/skill-design.md create mode 100644 docs/skill/references/discipline/core-rules.md create mode 100644 docs/skill/references/discipline/operating-modes.md create mode 100644 docs/skill/references/docs/guides/notebook-composition-and-cache.md create mode 100644 docs/skill/references/docs/guides/product-export.md create mode 100644 docs/skill/references/domains/drawing-inspection-evidence.md create mode 100644 docs/skill/references/ql-recipes.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/role-1-drawing-confirmer.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/role-2-master-planner.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/role-3-verifier-planner.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/role-4-detail-builder.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/role-5-exporter.md create mode 100644 docs/skill/references/workflows/drawing-reconstruction/roundtrip-acceptance.md create mode 100644 docs/skill/references/workflows/scan-reconstruction.md create mode 100644 docs/skill/references/workflows/single-part-modeling/requirements-template.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-1-ask-bindings.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-1-requirements.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-2-plan.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-3-host-bindings.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-3-verifier.md create mode 100644 docs/skill/references/workflows/single-part-modeling/role-4-build.md create mode 100644 docs/skill/references/workflows/single-part-modeling/verifier-contract-patterns.md create mode 100644 src/simplecadapi/skill/lint.py create mode 100644 test/test_skill_lint.py diff --git a/docs/skill-design.md b/docs/skill-design.md new file mode 100644 index 00000000..1275295b --- /dev/null +++ b/docs/skill-design.md @@ -0,0 +1,136 @@ +# The skill: design contract + +`docs/skill/` is the skill an agent loads to use SimpleCADAPI. This file is the contract its pages follow. The rules here +are checked by `python tools/skillbuild.py --check` (see "Checks"), so a page that breaks one fails the build. + +## What the skill optimises for + +1. **Few agent turns.** An agent reads what a step needs in **one parallel batch** and then works. Pages are small and + have one job so they can be loaded separately, and the router tells the agent which pages belong together. +2. **A small reading budget.** The first batch of any route is at most **1,200 compiled lines** (router, the two + always-read pages, the workflow page and its `Read now` files); every later batch stays under the same bound. (Measured before this design: a median of 1,493 lines and 7 files read before the first write, p90 2,501 + lines and 20 files.) +3. **One home for each rule.** A rule is stated once, with its reason; other pages link to it. +4. **Two modes.** Every workflow runs interactive or unattended (`references/discipline/operating-modes.md`). +5. **Four curated targets.** The skill compiles to seven harness targets; `zcode`, `opencode`, `claudecode` and `codex` are + written for. `omp`, `dsh` and `kimicode` are compiled from the same sources and must pass the checks, with generic wording. + +## The layers + +```text +SKILL.md router: start here, route table with read sets, always-on rules (<= 130 lines) +references/workflows/ one lifecycle per task; each has Read now / Read when / Unattended mode +references/workflows// the role or stage pages of a long workflow, loaded when the role starts +references/domains/ capabilities (what the SDK offers for a kind of task) +references/discipline/ reasoning, invariants, rules (core-rules, operating-modes, the notebook rules ...) +references/docs/guides/ how the runtime works +references/docs/api|stdlib|core/ generated signatures (never hand-edited) +``` + +## Page contracts + +**Every hand-written page** (everything but `docs/api`, `docs/stdlib`, `docs/core`) is at most **300 lines** (`SKILL.md` +at most **130**). A longer page is split by job, not trimmed. The one exception is a versioned run contract (its first lines +say `Contract ID:`), which an external harness reads whole. + +**Domain and discipline pages** open, within their first 8 lines, with `**Read when:** .` Nothing else is +required of their body. + +**Workflow pages** (`references/workflows/*.md`) have these sections, with exactly these headings: + +```markdown +## Read now (one parallel batch) +| File | Why | +| --- | --- | +| `domains/requirement-refinement.md` | ... | + +## Read when +| When | File | +| --- | --- | +| a Role starts | `workflows//role-1-....md` | + +## Unattended mode +| Interactive gate | Unattended replacement | +| --- | --- | +| Role 1 consent gate | ... | +``` + +- `Read now` lists every file the first batch needs besides the always-read pages, **including the workflow's own role-1/first-stage page**; paths are + written relative to `references/` (for example `domains/part-modeling.md`) and in backticks, one per table row. +- `Read when` lists everything else with its trigger; nothing is read "just in case". +- `Unattended mode` has one row for **every** place the workflow asks a person, waits for one, or needs a second reviewer + (search the old text for `⛔`, "ask", "user", "consent", "confirm", "question", "review"), using the replacements in + `discipline/operating-modes.md`. A workflow with no such place says so in one sentence. +- `core-rules.md` and `operating-modes.md` are read on every route (the router says so), so `Read now` does not list them. +- The compiled lines of `SKILL.md` + `core-rules.md` + `operating-modes.md` + the workflow page + its `Read now` files are at + most **1,200** (checked per target; the lint prints the sizes with `--read-budget`). + +**The router's route table** repeats the workflow page plus its `Read now` set in its own column, so an agent can issue the batch +straight from the table without opening the workflow first. The two must agree (checked). + +## Parallel reading + +The router tells the agent to read a batch in one go. How is per target (compile blocks in `SKILL.md`): + +- `claudecode`, `zcode`: all `Read` calls of the batch in one message. +- `opencode`: all `read` calls of the batch in one response. +- `codex`: one shell command that prints the batch: `cd && tail -n +1 a.md b.md c.md` (each file is preceded by a + `==> name <==` header). +- `omp`, `dsh`, `kimicode`: the generic sentence ("request the whole batch together if your tools allow it"). + +Pages that name API pages for a step list them as one batch too (`Read now` / a `Read when` row), never "read as needed". + +## Splitting and merging rules (no loss) + +- **Move, do not rewrite.** When a long page is split, each rule, table row, example, checklist item and `skill:if` block + survives in exactly one new page (or is deleted as a verbatim duplicate of text kept elsewhere, and the report says + where). Changing meaning is a separate, labelled edit. +- The old page's name stays as the outline (workflow or guide) so links keep working; the outline links to the new pages. +- Stable file names are in the file map below. Cross-links between owners only use the names listed there. + +## File map + +Names in **bold** are new. + +```text +SKILL.md +references/README.md generated tables + this layering +references/SDK_OVERVIEW.md, ql-playbook.md, scadpkg-format.md +**references/ql-recipes.md** the recipes split out of ql-playbook.md +references/workflows/ + single-part-modeling.md outline, Role 5 (export), failure routes + single-part-modeling/**role-1-requirements.md, requirements-template.md, role-2-plan.md, + role-3-verifier.md, verifier-contract-patterns.md, role-4-build.md** + assembly-product-build.md step-reconstruction.md engineering-drawing.md export-and-translation.md + **scan-reconstruction.md** a mesh, point cloud or images as the source + drawing-reconstruction.md outline + drawing-reconstruction/**(role / stage pages, the owner chooses the names)** +references/domains/ the 11 existing pages (drawing-inspection may gain a template page) +references/discipline/ + **core-rules.md** **operating-modes.md** feature-tree-convention.md notebook-cell-design.md feature-ordering.md + geometric-validation.md datums-and-coordinate-systems.md mechanical-modeling.md manufacturing-boundaries.md + assembly-positioning.md failure-and-repair.md requirement-and-cad-brief.md verifier-scripts.md +references/docs/guides/ + notebook-runtime.md run, parameters (<= 300 lines) + **notebook-composition-and-cache.md** composing notebooks, the cell cache, library parts + **product-export.md** package export, downstream targets +``` + +## Checks (`tools/skillbuild.py --check`, tests in `test/test_skill_lint.py`) + +1. every path a page names (backticked or in a link, under `references/`) exists; +2. `references/README.md` lists exactly the workflows, domains and discipline pages that exist; +3. every hand-written page is reachable from `SKILL.md` through links; +4. line limits (300 per page, 130 for `SKILL.md`); +5. every workflow has the three sections above and every domain/discipline page has `**Read when:**`; +6. the route table's read set equals the workflow's `Read now` set; +7. the read budget (1,200 compiled lines, see "Workflow pages") for each route and each target; +8. the four curated targets' compiled `SKILL.md` contains the parallel-read instruction; +9. no unresolved `skill:if` / `skill:endif`. + +## Changing the skill + +Run `python tools/skillbuild.py --check`. To see what a harness gets, `python tools/skillbuild.py --target opencode` +(output goes to `skills/simplecadapi-opencode/`, which git ignores). Behaviour is measured, not assumed: the +agents' traces (Langfuse) give the lines read before the first write and the rules broken; a change that adds reading +needs a reason in its commit message. diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 93fe6d28..653b4257 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: simplecadapi -description: Build, assemble, inspect, reconstruct, and export parametric CAD models with the SimpleCADAPI Python SDK. Use for SimpleCAD geometry modeling, constrained sketches, parts and assemblies, standard gears and bearings, STEP/BREP inspection and reconstruction, vector-PDF drawing reconstruction, durable product packages, model JSON replay, and CAD backend translation. +description: Build, assemble, inspect, reconstruct, and export parametric CAD models with the SimpleCADAPI Python SDK. Use for SimpleCAD geometry modeling, constrained sketches, parts and assemblies, standard gears and bearings, STEP/BREP inspection and reconstruction, mesh or image based reconstruction, vector-PDF drawing reconstruction, durable product packages, model JSON replay, and CAD backend translation. license: Apache-2.0 metadata: project: simplecadapi @@ -11,217 +11,84 @@ metadata: # SimpleCAD SDK Skill -This file is the router: it owns load order, route selection, and -execution discipline only. Each selected workflow owns its procedure. -Classify the request, load exactly one workflow, follow its domain and -discipline references, and read exact API pages for the APIs its steps -use. - -## Mandatory load order - -Paths are relative to this file's directory. - -1. Read this file. -2. Select exactly one workflow from Task routing. If two rows - plausibly match, ask one discriminator question that names the - candidate outcomes; never pick silently between routes with - different deliverables. -3. Read the selected workflow in full. From selection on, it owns the - procedure; do not load another workflow's procedure. -4. Load each required domain/discipline file the workflow names - before the step that uses it; optional ones only when their - trigger fires. -5. Before the first call of any API, read its exact page - (`references/docs/api/.md`, `references/docs/stdlib/.md`, - `references/docs/core/.md`). A method absent from its page - does not exist, however plausible it looks. - -- You MUST NOT read the full API or stdlib index up front; the workflow - names every page its steps need. -- Routing questions are decided here; after selection the workflow owns - execution. You MUST always follow the global rules and execution - discipline below. -- Questions about an existing STEP file that change nothing enter no - workflow; you MUST use `references/domains/step-inspection.md` for them - directly. -- A person who has the code-first editor open (`sca edit`) and talks - about what they selected, a cell they copied, or a value they - tuned enters no new workflow: the modeling follows the workflow you - selected, and you MUST read - `references/domains/editor-collaboration.md` first to see what they - see (selection cell, `sca snapshot`, cell references) and how to - hand the work back. While the editor runs, `sca cell` is the only way to - change a regular cell; edit the file only when no editor is running, or - for the setup / `@app.function` cells `sca cell` refuses: the editor is - the file's writer. -- Creating, installing, updating, or removing a SimpleCADAPI addon (a - third-party skill+tooling package around `.scadpkg`) enters no - workflow; you MUST use `references/domains/addon-development.md` - directly. - -## Task routing - -Read exactly one workflow first, per the user's goal: - -| User goal | Workflow | -| --- | --- | -| Model one physical part | `references/workflows/single-part-modeling.md` | -| Multi-part product or mechanism (custom parts, stdlib gears/bearings, connectors, constraints, package) | `references/workflows/assembly-product-build.md` | -| Rebuild an editable model from a STEP file | `references/workflows/step-reconstruction.md` | -| Export/translate a validated package | `references/workflows/export-and-translation.md` | -| Produce a GB engineering drawing (DXF) from validated geometry | `references/workflows/engineering-drawing.md` | -| Rebuild a 3D model from a vector-PDF engineering drawing | `references/workflows/drawing-reconstruction.md` | - -Do not present route-choice menus when a row already matches: profile, -strategy, and tool choices inside a route belong to that workflow's -own gates, not to an up-front question. A missing prerequisite — state -it and stop that route; never invent an alternative route. - -## Routing discipline - -| Rule | Behavior | -| --- | --- | -| One lifecycle per request | Every task enters exactly one workflow; domains, disciplines, and guides refine it and are never competing routes | -| Deterministic selection | When the matrix matches, proceed without asking a route question | -| Ambiguous request | Ask one discriminator question naming the candidate deliverables (editable model vs inspection evidence vs export package), then route | -| Missing prerequisite | Name what is missing and stop that route | -| Explicit user override | Honor it only when the target route's scope actually covers the request | - -## Global rules (every task) - -1. Refine the requirement into a brief before modeling - (`references/domains/requirement-refinement.md`). -2. Use keyword arguments for every documented public API and - stdlib function, except the canonical durable export call - `capture(result, path)`, whose two required arguments are - positional. -3. Every model is a marimo notebook with a `[tool.simplecadapi]` - header (`references/docs/guides/notebook-runtime.md`): one part - per notebook; one assembly notebook per product, composing - children with `scad.use(...)`; parameters live in the notebook - that consumes them; exposed tunable parameters are `var()`/`Var` - declarations (optionally with `unit`, `tolerance`). Part sources - follow the Feature Tree Convention - (`references/discipline/feature-tree-convention.md`): block - structure `sketch → basic body op → bool → modifier`, one - feature per block and one block per cell, with a mandatory - boundary comment `# ---- feature: () ----`; 2D - profiles and planar paths go through the sketch API, primitives - only when the shape is completely contained in the basic form. - Where the cell boundaries go and which numbers are a `var()` - follow `references/discipline/notebook-cell-design.md`: read it - with the convention before writing any notebook; it states each rule - with its reason and the error you get when you break it. -4. Booleans (`union_rsolid`, `cut_rsolid`, `cut_rsolid_list`, `intersect_rsolid`) - accept mixed inputs and return exactly one `Solid`; union - defaults to `glue=False` with a conservative scale-relative - tolerance and fails explicitly when it cannot produce one - merged solid. -5. Build and validate incrementally: each major step prints small - QL-derived facts; grounding uses QL wherever possible; never - print whole solids or full model objects. -6. Topology enumeration is QL-only and strictly enforced: plural - getters (`get_edges`, `get_faces`, `get_wires`, `get_vertices`, - `get_solids`, `get_inner_wires`) accept ONLY an index — the - no-argument list form raises. Enumerate, count, and measure via - `ql.().resolve(shape)`; filter with QL predicates plus - `take`/`exactly` cardinality; never build selections by looping - over an enumerated list and indexing it. Indexed picks - (`get_edges(index)`) are reserved for intentional, named choices - and are recorded as graph selection nodes. -7. Tags: attach with `apply_tag(shape=..., tag=...)` (LOCAL scope — - it never propagates downward); inspect with - `list_tags(shape=...)`; keep numeric facts in metadata, never in - tags. -8. The notebook file is the only source of truth; `.scadpkg` is - generated from it for exchange and publishing (`sca run - product.py --out out/product.scadpkg`, or - `capture(result, "out/product.scadpkg")` in one call) and is - never read back as a source. `@scad.part` / `@scad.assemble` - are for reusable library parts in plain modules; they may be - called inside a notebook cell. -9. `simplecadapi.inspect.brep` and `simplecadapi.inspect.drawing` are - diagnostic-only and rejected inside `GraphSession`; obtain/export - geometry first, inspect outside. -10. Standard parts first: before hand-modeling a gear, ring gear, - rack, cycloidal disc, or bearing, check `scad.std.gear` / - `scad.std.bearing`. -11. Read `references/docs/guides/notebook-runtime.md` in full - before writing a notebook, composing notebooks, or reasoning - about the cell cache. Run models with `sca run` (`sca edit` opens - them in the editor for a person); never enable - marimo's `[tool.marimo.runtime] cache_cells` in the editor, and - never commit the generated `__marimo__/` directory. -12. Manage the modeling project the way software is managed: put the - workspace under git from the first file, commit at verified - milestones (a passed verification, not a wall clock) with messages - that say what changed and why, and use a branch per design - direction when alternatives are on the table. Generated state — - `__marimo__/`, `.sca-editor/`, `out/` — belongs in `.gitignore`, - never in a commit. - -## Boundaries - -- `.scadpkg` is the canonical durable product; STL/OBJ/MJCF are - point-in-time exports, never editable sources. -- Model JSON is the replay/interchange contract for explicit - `GraphSession` flows; never hand-author payloads. -- No claims of strength, fatigue, thermal, vibration, tolerance - compliance, or regulatory fitness without the corresponding - analysis actually run. - -## Execution discipline (every task) - -1. Gates bind artifacts: a validation gate passes only when its named - artifact exists — printed QL facts, targeted measurements, named - rendered views, completed brief fields. A stated feeling of success - is not a gate result. -2. Blocking waits: an Ask-or-Record blocking item resolves before any - geometry — asked once (batched into one question set) or recorded - as an explicit assumption. Proceeding with neither is the failure, - not the asking. -3. Serial phases: do not preload later-phase pages or build - later-phase artifacts early. After an interruption, re-ground from - the owning artifact (source file, brief), not from conversation - memory. -4. Repair the owning source: the named parameter, the brief field, or - the upstream selection — never the downstream symptom. - Candidate x parameter enumeration loops are diagnosis debt, not - repair (`references/discipline/failure-and-repair.md`). -5. A failed check closes by changing the design or by redefining the - check with the user — never by weakening or deleting the check. - -## Example SDK usage - -```python -import simplecadapi as scad -from simplecadapi import GraphSession, export_model_json, replay_model_json - -with GraphSession(graph_id="box") as session: - shape = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) - session.capture_result(value=shape) - payload = export_model_json(session=session) - -rebuilt = replay_model_json(json_str=payload) -print(len(rebuilt)) -``` - -## References - -- `references/README.md` — skill layer structure -- `references/workflows/` — goal-oriented workflows, including drawing reconstruction -- `references/domains/` — capability domains, including drawing inspection -- `references/discipline/` — modeling knowledge and invariants -- `references/discipline/verifier-scripts.md` — writing a verifier script (OCP through `simplecadapi.verify`) -- `references/SDK_OVERVIEW.md` — package-level map -- `references/inspect/brep-reverse-engineering.md` -- `references/domains/addon-development.md` — `sca` addon CLI and authoring guide -- `references/domains/editor-collaboration.md` — working with a person in `sca edit`: - selection cell, snapshot, cell references, `sca cell` / `sca new`, parameters -- `references/docs/guides/reconstruction-agent-test-prompt.md` -- `references/docs/guides/reconstruction-agent-strategy.md` -- `references/docs/guides/notebook-runtime.md` -- `references/ql-playbook.md` -- `references/scadpkg-format.md` — `.scadpkg` consumer spec: member - layout, tag channel, minimal readers (addon exporters read this) -- `references/docs/api/`, `references/docs/stdlib/`, `references/docs/core/` +This file is the router. Pick the route, read its batch, work. Paths are relative to this file's directory. + +## Start here + +1. **Decide the mode** (`references/discipline/operating-modes.md`): interactive (a person can answer) or unattended (nobody + will). Write `Mode: ...` as the first line of the brief. The mode changes who decides, never what is checked. +2. **Pick one route** from the table. Do not ask a route question when a row matches; if two rows plausibly match, ask one + question naming the candidate deliverables (unattended: take the row whose deliverable the request names, record the choice). +3. **Read the whole batch of that row at once**, plus the two always-read pages `references/discipline/core-rules.md` and + `references/discipline/operating-modes.md`. Then work; the workflow's `Read when` table says when to read anything else. +4. Before the first call of an API, read its page (`references/docs/api|stdlib|core/.md`) in the same batch as the + other pages of that step. A method absent from its page does not exist. Never read the whole index. + +## Reading in parallel + +Reading is cheap when it is one request and expensive when it is ten. Ask for a whole batch together. + + +Issue every `Read` call of the batch in one message, so the harness runs them together. Do not read a file, think, then +read the next. + + +Issue every `read` call of the batch in one response, so the harness runs them together. Do not read a file, think, then +read the next. + + +Print the whole batch with one shell command from this skill's directory, for example +`tail -n +1 references/discipline/core-rules.md references/discipline/operating-modes.md references/workflows/single-part-modeling.md` +(each file is preceded by a `==> name <==` line). One command, not one per file. + + +Request the whole batch together if your tools allow several reads in one step. + + +## Routes + +Each row's batch is the workflow page plus its `Read now` set (the workflow page repeats it). + +| You are asked to | Workflow | Read now | +| --- | --- | --- | +| Model one physical part | `references/workflows/single-part-modeling.md` | `references/workflows/single-part-modeling.md` `references/workflows/single-part-modeling/role-1-requirements.md` `references/workflows/single-part-modeling/requirements-template.md` `references/domains/requirement-refinement.md` `references/discipline/requirement-and-cad-brief.md` | +| Build a multi-part product or mechanism | `references/workflows/assembly-product-build.md` | `references/workflows/assembly-product-build.md` `references/domains/requirement-refinement.md` `references/domains/assembly-and-product.md` `references/discipline/assembly-positioning.md` | +| Rebuild an editable model from a STEP file | `references/workflows/step-reconstruction.md` | `references/workflows/step-reconstruction.md` `references/domains/step-inspection.md` `references/inspect/brep-reverse-engineering.md` | +| Rebuild a model from a mesh, point cloud or photos | `references/workflows/scan-reconstruction.md` | `references/workflows/scan-reconstruction.md` `references/domains/step-inspection.md` `references/domains/part-modeling.md` | +| Rebuild a model from a vector-PDF drawing | `references/workflows/drawing-reconstruction.md` | `references/workflows/drawing-reconstruction.md` `references/workflows/drawing-reconstruction/role-1-drawing-confirmer.md` `references/domains/drawing-inspection.md` `references/domains/requirement-refinement.md` | +| Produce a GB engineering drawing (DXF) from validated geometry | `references/workflows/engineering-drawing.md` | `references/workflows/engineering-drawing.md` `references/domains/engineering-drawing.md` `references/domains/requirement-refinement.md` | +| Export or translate a validated package | `references/workflows/export-and-translation.md` | `references/workflows/export-and-translation.md` `references/domains/export-and-translation.md` | + +Routes without a workflow (no lifecycle, read the page and answer): + +- A question about an existing STEP file that changes nothing: `references/domains/step-inspection.md`. +- A person has the editor open (`sca edit`) and talks about what they selected or tuned: keep the workflow you are in and + read `references/domains/editor-collaboration.md` first. While the editor runs, `sca cell` is the only way to change a + regular cell. +- Creating, installing, updating or removing an addon (`.scadpkg` skill + tooling): `references/domains/addon-development.md`. + +An explicit request for a route is honoured only when that workflow's scope covers it. +A missing prerequisite (a file, a tool, an input): name it and stop that route; never invent another route. Unattended: record +it as a blocker in the report and do the strongest supported work. + +## Always-on rules + +`references/discipline/core-rules.md` has the full text with reasons; the IDs below are what reviews cite. + +- **R2** keyword arguments for every documented API (except `capture(result, path)`). +- **R3** a model is a marimo notebook; cell boundaries and `var()` follow `references/discipline/notebook-cell-design.md`, + blocks follow `references/discipline/feature-tree-convention.md`. Read both before writing a cell. +- **R6** topology is enumerated through QL only; plural getters take only an index (`references/ql-playbook.md`). +- **R13** an API not on its page does not exist. +- **P1** gates bind artifacts, not feelings. **P2** resolve blocking items before geometry (ask once, or record). + **P5** repair the owner, not the symptom. **P6** a failed check closes by changing the design or redefining the check in + the open, never by weakening it. +- No claim of strength, fatigue, thermal, vibration, tolerance or regulatory fitness without the analysis run. + +## Where things are + +- `references/README.md`: every page by layer (workflows, domains, discipline, guides, generated API docs). +- `references/SDK_OVERVIEW.md`: package map. `references/ql-playbook.md` and `references/ql-recipes.md`: selection. +- `references/scadpkg-format.md`: the `.scadpkg` consumer spec. +- `references/docs/api/`, `references/docs/stdlib/`, `references/docs/core/`: exact signatures. +- Maintainers: the contract these pages follow is `skill-design.md` in the repository's `docs/` folder. diff --git a/docs/skill/references/README.md b/docs/skill/references/README.md index b86118ab..d295b8c8 100644 --- a/docs/skill/references/README.md +++ b/docs/skill/references/README.md @@ -1,95 +1,110 @@ # Skill Documentation Structure -This directory is the task-routed skill layer. It sits between the SDK's -generated +The skill is task-routed: `SKILL.md` (one level up) is the router, and everything here is loaded in batches the router and the +workflows name. The contract the pages follow (sizes, headers, read sets, unattended mode, checks) is `skill-design.md` +in the repository's `docs/` folder; `python tools/skillbuild.py --check` enforces it. ```text -docs/skill/ - domains/ stable, reusable capability modules - workflows/ goal-oriented compositions of domains - discipline/ mechanical modeling knowledge and invariants +SKILL.md router: mode, routes with read sets, always-on rules +references/discipline/core-rules.md, operating-modes.md read on every route +references/workflows/.md one lifecycle: Read now / Read when / Unattended mode +references/workflows// the role or stage pages of a long workflow +references/domains/.md what the SDK offers for a kind of task +references/discipline/.md reasoning, invariants, rules +references/docs/guides/ how the runtime works +references/docs/api|stdlib|core/ exact signatures (generated; never edited by hand) ``` -## Layering +Load top-down and only what the task triggers; read each batch in one parallel request. -```text -SKILL.md (router: load order, routing, execution discipline) -→ workflows/.md what to run, in what order -→ domains/.md capability boundaries and API groups -→ discipline/.md why: modeling reasoning and invariants -→ docs/api|stdlib|core/ exact signatures (auto-generated) -``` +## Workflows -Load top-down and only what the current task triggers. No task reads the -whole API index up front. +| Workflow | Scenario | +| --- | --- | +| `workflows/single-part-modeling.md` | one physical part from brief to validated solid (outline: roles, role switches, export, failure routes) | +| `workflows/assembly-product-build.md` | multi-part product or mechanism: connectors, constraints, package capture | +| `workflows/step-reconstruction.md` | editable rebuild from a target STEP with tiered acceptance | +| `workflows/scan-reconstruction.md` | rebuild from a mesh, point cloud or photos: what the SDK supports and what it does not | +| `workflows/drawing-reconstruction.md` | editable rebuild from vector-PDF dimensions (outline; stage pages in `workflows/drawing-reconstruction/`) | +| `workflows/engineering-drawing.md` | GB engineering drawing (DXF) from validated geometry | +| `workflows/export-and-translation.md` | downstream formats from a validated package | -## Domains +Stage and role pages of the long workflows: -| Domain | Owns | +| Page | Read when | | --- | --- | -| `requirement-refinement` | request → brief (dimensions, units, datums, validation targets) | -| `part-modeling` | single-solid parts: primitives, profiles, features, booleans, transforms | -| `sketch-and-features` | declarative constrained sketches and promotion | -| `assembly-and-product` | parts, placements, connectors, constraints, notebooks and `scad.use`, @part/@assemble, `.scadpkg` | -| `standard-parts` | stdlib gears, ring gears, racks, cycloidal discs, bearings | -| `step-inspection` | STEP/BREP evidence, comparison, reconstruction evaluation | -| `drawing-inspection` | vector-PDF coordinates and dimensions, model-section measurements, independent evidence validation | -| `export-and-translation` | STEP/STL/OBJ/MJCF export, FreeCAD/Fusion/SolidWorks translation | -| `addon-development` | third-party addon authoring and management: `sca` CLI, `sca-addon.toml` descriptor, runtime probes, addon SKILL.md requirements | +| `workflows/single-part-modeling/requirements-template.md` | Single-Part Modeling: REQUIREMENTS.md template | +| `workflows/single-part-modeling/role-1-ask-bindings.md` | Role 1 (interactive mode) is about to send its batched ask round. | +| `workflows/single-part-modeling/role-1-requirements.md` | Single-Part Modeling: Role 1, Requirement Confirmer | +| `workflows/single-part-modeling/role-2-plan.md` | Single-Part Modeling: Role 2, Master Planner | +| `workflows/single-part-modeling/role-3-host-bindings.md` | Role 3 starts a stage (the hypothesis loop and the TODO state machine for the Verifier Planner). | +| `workflows/single-part-modeling/role-3-verifier.md` | Single-Part Modeling: Role 3, Verifier Planner | +| `workflows/single-part-modeling/role-4-build.md` | Single-Part Modeling: Role 4, Detail Modeling Planner & Builder | +| `workflows/single-part-modeling/verifier-contract-patterns.md` | Role 3 selects the checks for a stage. | +| `workflows/drawing-reconstruction/role-1-drawing-confirmer.md` | Drawing Reconstruction: Role 1, Drawing Requirement Confirmer | +| `workflows/drawing-reconstruction/role-2-master-planner.md` | Drawing Reconstruction: Role 2, Master Planner | +| `workflows/drawing-reconstruction/role-3-verifier-planner.md` | Drawing Reconstruction: Role 3, Verifier Planner | +| `workflows/drawing-reconstruction/role-4-detail-builder.md` | Drawing Reconstruction: Role 4, Detail Modeling Planner & Builder | +| `workflows/drawing-reconstruction/role-5-exporter.md` | Drawing Reconstruction: Role 5, Exporter | +| `workflows/drawing-reconstruction/roundtrip-acceptance.md` | Drawing Reconstruction: Roundtrip Acceptance | -Cross-cutting capabilities — QL grounding, semantic tags, GraphSession replay, -units/tolerances, incremental validation — are not routing targets; each -workflow pulls them in as conditions require. Verified QL selection patterns -for operation outputs (extrude-family slots, boolean seam curves, seam -fillets) live in `ql-playbook.md`. The `.scadpkg` consumer-facing format -spec (member layout, `interface.*` tag channel, minimal readers for addon -exporters) lives in `scadpkg-format.md`. - -## Workflows +## Domains -| Workflow | Scenario | +| Page | Read when | | --- | --- | -| `single-part-modeling` | one physical part from brief to validated solid — sketch tier by default, std parts first | -| `assembly-product-build` | multi-part product or mechanism with connectors/constraints and package capture; stdlib gears/bearings live here | -| `step-reconstruction` | editable rebuild from a target STEP with tiered acceptance | -| `drawing-reconstruction` | editable rebuild from vector-PDF dimensions with stage checks and independent drawing/model/output acceptance | -| `export-and-translation` | downstream formats from a validated package | - -Each workflow states: goal/scope, task decomposition (required/optional -domains, artifacts, validation gates, repair routes), ordered steps with the -domain/discipline/API references to load, failure routes, and deliverables. -`single-part-modeling` is additionally **role-structured**: five roles -(requirement confirmer → master planner → verifier planner ⇄ detail modeling -planner & builder → exporter), with Roles 3 and 4 looping per stage before -the next stage is allowed. Each role owns a disk artifact and an exit gate; -per-harness host bindings (interactive ask, TODO/plan tool, REPL-or-script -hypothesis loop, mandatory isolated image review) are declared inside the -workflow file as conditional blocks compiled per target by -`tools/skillbuild.py` (targets declared in `skillproj.toml`). +| `domains/addon-development.md` | you create, publish, install, update or remove a SimpleCADAPI addon (`sca-addon.toml`), or decide whether a capability is an addon or SDK work. | +| `domains/assembly-and-product.md` | the deliverable has more than one physical part, repeated instances, nested subassemblies, motion semantics, or a `.scadpkg` package (details under "Use when"). | +| `domains/drawing-inspection-evidence.md` | a section view of the drawing must be verified against the rebuilt model, a DIM ledger row needs its `measurement_contract`, or the roundtrip evidence (annotations, reviews, coverage) is built or checked. | +| `domains/drawing-inspection.md` | a vector-PDF engineering drawing must be parsed into tiered evidence, or a rebuilt model is read back for the roundtrip. | +| `domains/editor-collaboration.md` | a person has the code-first editor open (`sca edit`) and you edit the same notebooks (a selection, a cell reference or a tuned value arrives). | +| `domains/engineering-drawing.md` | a validated part or product needs a GB-standard drawing sheet (DXF + PNG). | +| `domains/export-and-translation.md` | a validated `.scadpkg` must be delivered as STEP, STL, OBJ, MJCF or an editable external CAD document. | +| `domains/part-modeling.md` | the deliverable is one physical single-solid part, or an existing part source needs a geometry edit (Role 4 of `workflows/single-part-modeling.md`). | +| `domains/requirement-refinement.md` | every task starts here: any modeling, assembly, reconstruction, or export request is refined into a brief first, and whenever inputs conflict, are ambiguous, or mix modalities. | +| `domains/sketch-and-features.md` | the part's controlling dimensions are geometric constraints, or an editable, constraint-driven profile is requested (sketch tier profiles and paths). | +| `domains/standard-parts.md` | the request names gears, ring gears, racks, cycloidal discs or ball bearings, or a mechanism built from them (details under "Use when"). | +| `domains/step-inspection.md` | the input is an existing `.step`/`.stp` file and you need facts out of it, or a reconstruction candidate must be compared against a target STEP (details under "Use when"). | ## Discipline -Modeling reasoning and invariants, independent of any single API: -`mechanical-modeling` (construction strategy), `requirement-and-cad-brief` -(input precedence, drawings), `datums-and-coordinate-systems`, -`feature-ordering` (operation order and boolean tool geometry), -`assembly-positioning` (mating/motion intent), `geometric-validation` -(validity vs volume, incremental grounding, spec-driven measurement), -`failure-and-repair` (failure classes and the repair loop), -`manufacturing-boundaries` (defaults and what geometry never proves). +| Page | Read when | +| --- | --- | +| `discipline/assembly-positioning.md` | you choose frames, connectors, placements or constraints for mating and motion, or placement comes out wrong after solving. | +| `discipline/core-rules.md` | every task, in the first batch (the router lists it). These are the rules that hold in every workflow and both modes. Each has an ID so other pages and reviews can cite it (`core-rules R6`). | +| `discipline/datums-and-coordinate-systems.md` | before the first primitive of a part or assembly, when choosing an origin, base plane or up axis, or when placing imported or factory geometry. | +| `discipline/failure-and-repair.md` | a build, solve, export or validation step fails, or the same check has failed twice. | +| `discipline/feature-ordering.md` | you plan the order of operations of a part, a fillet or chamfer fails, or you pattern a feature or loft sections. | +| `discipline/feature-tree-convention.md` | you author or edit any part source (every workflow): what a feature block is, its header and name. | +| `discipline/geometric-validation.md` | you write or run checks on a model: validity and volume gates, selection evidence before a fillet or chamfer, spec-driven measurements, replay, or what to report as having run. | +| `discipline/manufacturing-boundaries.md` | you apply default dimensions, or the request touches manufacturability, tolerance, strength or process claims. | +| `discipline/mechanical-modeling.md` | you choose a construction strategy or decompose a mechanical part or product before selecting SimpleCADAPI operations. | +| `discipline/notebook-cell-design.md` | Discipline: Notebook Cell Design | +| `discipline/operating-modes.md` | at the start of every task (the router sends you here), and again whenever a gate says to ask or to wait for a person. | +| `discipline/requirement-and-cad-brief.md` | Role 1 (or any brief-writing step) starts: prose, images or drawings are being turned into a brief, or evidence conflicts. | +| `discipline/verifier-scripts.md` | Role 3 writes the executable check for a stage, or any task needs a script that measures a finished model against the specification. | + +## Guides and references + +| Page | What | +| --- | --- | +| `docs/guides/README.md` | Engineering Guides | +| `docs/guides/notebook-composition-and-cache.md` | Notebook Composition and the Cell Cache | +| `docs/guides/notebook-runtime.md` | Notebook Runtime and Product Build Workflow | +| `docs/guides/product-export.md` | Product Package Export and Downstream Targets | +| `docs/guides/reconstruction-agent-strategy.md` | Reconstruction Agent Strategy | +| `docs/guides/reconstruction-agent-test-prompt.md` | Reconstruction Agent Test Contract | +| `ql-playbook.md` | how QL selection works, with the rules | +| `ql-recipes.md` | worked selection recipes | +| `scadpkg-format.md` | the `.scadpkg` consumer spec | +| `SDK_OVERVIEW.md` | package map | +| `inspect/brep-reverse-engineering.md` | normative BREP inspection guide | ## Authoring rules -- Domain files say what a capability owns, when to use it, when to route - elsewhere, and which API pages to read — they do not restate signatures. -- Workflow files compose domains; they never duplicate discipline content. -- Discipline files state reasoning and invariants; they avoid CLI- or - tool-specific detail that belongs in domains/workflows. -- Every `references/...` path written here must exist in the packaged skill. -- Reading an API page is an action with an output: before the first - call of any API, note its signature, return type, and documented - failure modes — one line each in the working notes. A method name - absent from the page does not exist, however plausible it looks. - Non-SDK dependencies (Gmsh, CalculiX, VTK, FreeCAD scripting) are not - covered by SDK pages: check their own documentation before the first - call. +- A page has one job and at most 300 lines. A rule is stated once, with its reason; other pages link to it. +- Domain pages say what a capability owns and which API pages to read; they do not restate signatures. +- Workflow pages compose domains and discipline; they never duplicate discipline content. +- Every workflow has `Read now`, `Read when` and `Unattended mode`, with one unattended row for every gate that asks a person. +- Every path written in a page must exist in the packaged skill. +- Reading an API page is an action with an output: note the signature, return type and documented failures before the + first call. Non-SDK dependencies (Gmsh, CalculiX, VTK, FreeCAD scripting) are not covered by SDK pages. diff --git a/docs/skill/references/SDK_OVERVIEW.md b/docs/skill/references/SDK_OVERVIEW.md index 5024cb4a..c7cd602a 100644 --- a/docs/skill/references/SDK_OVERVIEW.md +++ b/docs/skill/references/SDK_OVERVIEW.md @@ -1,12 +1,12 @@ # SDK Overview - Project: `simplecadapi` -- Version: `2.0.4b3` -- Package distribution: `simplecadapi==2.0.4b3` +- Version: `2.2.7` +- Package distribution: `simplecadapi==2.2.7` ## What this skill bundles -- Task router (`SKILL.md`) with six workflows +- Task router (`SKILL.md`) with seven workflows (including unattended mode) - Capability domains (`references/domains/`) - Modeling discipline (`references/discipline/`) - Generated API/stdlib/core references (`references/docs/`) @@ -31,3 +31,18 @@ - Record modeling steps inside `GraphSession` when you need replayable outputs. - Export session/model payloads with `export_session_json()` and `export_model_json()`. - Re-import or replay with `import_model_json()` and `replay_model_json()`. + +## Example SDK usage + +```python +import simplecadapi as scad +from simplecadapi import GraphSession, export_model_json, replay_model_json + +with GraphSession(graph_id="box") as session: + shape = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) + session.capture_result(value=shape) + payload = export_model_json(session=session) + +rebuilt = replay_model_json(json_str=payload) +print(len(rebuilt)) +``` diff --git a/docs/skill/references/discipline/assembly-positioning.md b/docs/skill/references/discipline/assembly-positioning.md index b708666e..b1f82836 100644 --- a/docs/skill/references/discipline/assembly-positioning.md +++ b/docs/skill/references/discipline/assembly-positioning.md @@ -1,5 +1,7 @@ # Discipline: Assembly Positioning +**Read when:** you choose frames, connectors, placements or constraints for mating and motion, or placement comes out wrong after solving. + How SimpleCADAPI represents mating and motion intent, plus engineering guidance for choosing frames and placements. diff --git a/docs/skill/references/discipline/core-rules.md b/docs/skill/references/discipline/core-rules.md new file mode 100644 index 00000000..df4e1e95 --- /dev/null +++ b/docs/skill/references/discipline/core-rules.md @@ -0,0 +1,80 @@ +# Discipline: Core Rules + +**Read when:** every task, in the first batch (the router lists it). These are the rules that hold in every workflow and +both modes. Each has an ID so other pages and reviews can cite it (`core-rules R6`). + +## Modelling rules + +**R1. Refine before you model.** Turn the request into a brief first (`domains/requirement-refinement.md`). Geometry +before a brief is rework. + +**R2. Keyword arguments.** Every documented public API and stdlib function takes keyword arguments. The one exception is +the durable export call `capture(result, path)`, whose two required arguments are positional. + +**R3. A model is a marimo notebook** with a `[tool.simplecadapi]` header (`docs/guides/notebook-runtime.md`): one part per +notebook; one assembly notebook per product, composing children with `scad.use(...)`; parameters live in the notebook that +consumes them. Cell boundaries, `_private` names and `var()` follow `discipline/notebook-cell-design.md`; block structure +and the mandatory header `# ---- feature: () ----` follow `discipline/feature-tree-convention.md`. Read both +before writing any cell. + +**R4. Sketch first.** 2D profiles and planar paths go through the sketch API; primitives only when the shape is entirely +the basic form. + +**R5. Booleans** (`union_rsolid`, `cut_rsolid`, `cut_rsolid_list`, `intersect_rsolid`) accept mixed inputs and return +exactly one `Solid`. Union defaults to `glue=False` with a conservative scale-relative tolerance and fails explicitly when +it cannot produce one merged solid. + +**R6. Topology is enumerated through QL only.** Plural getters (`get_edges`, `get_faces`, `get_wires`, `get_vertices`, +`get_solids`, `get_inner_wires`) accept only an index; the no-argument list form raises. Enumerate, count and measure via +`ql.().resolve(shape)`; filter with QL predicates and `take`/`exactly`; never build a selection by looping over a +list and indexing it. An indexed pick is for an intentional, named choice. Details: `ql-playbook.md`. + +**R7. Tags.** `apply_tag(shape=..., tag=...)` is local: it never propagates downward. `list_tags(shape=...)` inspects. +Numeric facts go in metadata, never in tags. + +**R8. The notebook file is the only source.** `.scadpkg` is generated from it (`sca run product.py --out +out/product.scadpkg`, or `capture(result, "out/product.scadpkg")`) and is never read back as a source. `@scad.part` / +`@scad.assemble` are for reusable library parts in plain modules. + +**R9. Inspection is diagnostic.** `simplecadapi.inspect.brep` and `simplecadapi.inspect.drawing` are rejected inside +`GraphSession`; produce or export the geometry first, inspect outside. + +**R10. Standard parts first.** Before hand-modelling a gear, ring gear, rack, cycloidal disc or bearing, check +`scad.std.gear` / `scad.std.bearing`. + +**R11. Run with `sca run`;** `sca edit` opens the editor for a person. Never enable marimo's +`[tool.marimo.runtime] cache_cells`, never commit `__marimo__/`. + +**R12. Manage the project like software.** Put the workspace under git from the first file; commit at verified milestones +(a passed verification, not a clock) with messages that say what changed and why; use a branch per design direction when +alternatives are on the table. `__marimo__/`, `.sca-editor/` and `out/` go in `.gitignore`. + +**R13. An API that is not on its page does not exist.** Before the first call of any API read its exact page +(`docs/api/.md`, `docs/stdlib/.md`, `docs/core/.md`) in one batch with the others for that step. Never +read the whole index. + +## Process rules + +**P1. Gates bind artifacts.** A gate passes only when its named artifact exists: printed QL facts, targeted measurements, +named rendered views, completed brief fields. A feeling of success is not a result. + +**P2. Resolve a blocking item before any geometry.** Ask once, batched (interactive), or record it as an assumption with +its evidence (unattended; `discipline/operating-modes.md`). Proceeding with neither is the failure, not the asking. + +**P3. Serial phases.** Do not build later-phase artifacts early. After an interruption, re-ground from the owning +artifact (source file, brief), not from conversation memory. + +**P4. Build and check in small steps.** Each major step prints small QL-derived facts; never print whole solids or model +objects. + +**P5. Repair the owner.** Fix the named parameter, the brief field or the upstream selection, never the downstream +symptom. Candidate-by-parameter enumeration loops are diagnosis debt (`discipline/failure-and-repair.md`). + +**P6. A failed check closes by changing the design, or by redefining the check in the open** (with the person, or in the +report when unattended), never by weakening or deleting it. + +## Boundaries + +- `.scadpkg` is the durable product; STL/OBJ/MJCF are point-in-time exports, never sources. +- Model JSON is the replay contract for `GraphSession` flows; never hand-author payloads. +- No claim of strength, fatigue, thermal, vibration, tolerance or regulatory fitness without the analysis run. diff --git a/docs/skill/references/discipline/datums-and-coordinate-systems.md b/docs/skill/references/discipline/datums-and-coordinate-systems.md index 9b221dcb..5cf5c4c1 100644 --- a/docs/skill/references/discipline/datums-and-coordinate-systems.md +++ b/docs/skill/references/discipline/datums-and-coordinate-systems.md @@ -3,6 +3,9 @@ Choosing origins, planes, and axes from function before the first primitive. The coordinate and unit statements below are SDK facts where labeled; origin choices and part-type defaults are modeling guidance. + +**Read when:** before the first primitive of a part or assembly, when choosing an origin, base plane or up axis, or when placing imported or factory geometry. + ## Core rule The part-local coordinate system is chosen from the functional datum, and the diff --git a/docs/skill/references/discipline/failure-and-repair.md b/docs/skill/references/discipline/failure-and-repair.md index fd297636..41f386f4 100644 --- a/docs/skill/references/discipline/failure-and-repair.md +++ b/docs/skill/references/discipline/failure-and-repair.md @@ -3,6 +3,8 @@ The loop for generation, solve, export, or validation failures. API behavior is marked as contract; diagnosis heuristics are marked as modeling guidance. +**Read when:** a build, solve, export or validation step fails, or the same check has failed twice. + ## Evidence labels - **SDK contract** is limited to behavior exposed by the public API or reports. @@ -22,6 +24,17 @@ marked as contract; diagnosis heuristics are marked as modeling guidance. Never suppress the symptom: no exception swallowing, no special-cased input, no patching exported or solved artifacts. +## Stopping rule + +If the same check fails twice for the same cause, do not retry the same thing a third time. Change the design (the +operation order, the selection, the feature hypothesis, the parameter that was guessed), or redefine the check **in the +open**: state the old definition, the new one and the reason (in the brief, the plan or the report). A check is never +weakened or deleted to make it pass. Count by cause, not by wording: two different exceptions from one tangent +contact are one failure. If the new design fails the same check again, stop and report what failed, what was tried and +which claims cannot be made (see "Reporting failed repairs"). Interactive: the redefinition is agreed with the person. +**Unattended:** decide it yourself from the evidence and record it in the report +(`discipline/operating-modes.md`: Stopping). + ## Failure classes ### Boolean failure @@ -159,6 +172,6 @@ A loop over candidate sets x parameter values (radii, zoom levels, being guessed that should be derived. Stop enumerating and derive it: radii from wall thickness or feature size, framing from the bounding box, cardinality from feature intent. If a value truly cannot be -derived, the check's definition is wrong — narrow it or ask. Swallowing +derived, the check's definition is wrong — narrow it or ask (unattended: narrow it and record the new definition in the report). Swallowing exceptions inside an enumeration loop (`except: pass`) converts a search into silent damage. diff --git a/docs/skill/references/discipline/feature-ordering.md b/docs/skill/references/discipline/feature-ordering.md index 16171928..bcd0419f 100644 --- a/docs/skill/references/discipline/feature-ordering.md +++ b/docs/skill/references/discipline/feature-ordering.md @@ -3,6 +3,8 @@ Operation order is a robustness recommendation, not an SDK-enforced canonical sequence. Put fragile steps late when that improves diagnosis and repair. +**Read when:** you plan the order of operations of a part, a fillet or chamfer fails, or you pattern a feature or loft sections. + ## Evidence labels - **SDK contract:** documented operation results and failure behavior. diff --git a/docs/skill/references/discipline/feature-tree-convention.md b/docs/skill/references/discipline/feature-tree-convention.md index 3c41c109..85d000a7 100644 --- a/docs/skill/references/discipline/feature-tree-convention.md +++ b/docs/skill/references/discipline/feature-tree-convention.md @@ -1,5 +1,7 @@ # Discipline: Feature Tree Convention (FTC) +**Read when:** you author or edit any part source (every workflow): what a feature block is, its header and name. + Part sources are block-structured feature chains: every block is one feature in the sense of a commercial CAD feature tree (Onshape / SolidWorks / Fusion 360), readable by humans and LLMs, and translatable by the package @@ -7,9 +9,8 @@ translators. This convention is mandatory for every part source authored in any workflow. A model is a marimo notebook (`docs/guides/notebook-runtime.md`), and each -block is one cell of it: the cell is the unit the runtime caches and re-runs, -so one feature per cell makes an edit re-run that feature and its downstream -features only. +block is one cell of it. Why the cell is the unit, and where cell boundaries go, +is in `notebook-cell-design.md` (rule C1 and the facts it rests on). ## The paradigm @@ -113,36 +114,22 @@ design parameters) stay in the source; geometric verification never does ## Blocks are cells -In a notebook every block is one `@app.cell`. This section is the structure; -`notebook-cell-design.md` gives the rules for where boundaries go, when a number -is a `Var`, and the reason for each (read it too): +In a notebook every block is one `@app.cell`. This section owns what a block +looks like inside its cell (header and name); `notebook-cell-design.md` owns the +rest, each rule with its reason: one feature per cell (C1) and when cells may +merge (C4), `_private` temporaries (C5), the parameters cell (C8), the product +cell (C9), local-module imports (C12). Read it too. - The block header is the first line of the cell body. - The cell returns the body under the block's slug in snake_case (`base-plate` → `base_plate`), and the next block reads that name. The variable names then spell the feature tree, and an override - (`sca run --set`) or a failure points at one feature. -- Each block binds a new name because marimo gives every top-level - variable exactly one defining cell: that is how it orders cells by data - dependency and keys each cell's cache by the values it reads. Rebinding - `body` in a second cell is a multiple-definition error. -- Tools, profiles, and other temporaries are cell-local: prefix them with - `_` (`_pockets = ...`) so marimo keeps them out of the notebook's - namespace. -- Several small blocks that always change together may share one cell, - rebinding the body inside it; the cell returns the body under the last - block's slug. They are then cached and re-run as one unit, so keep this - the exception. -- Parameters live in their own cell(s) at the top (`# ---- params ----`), - grouped by what is overridden together: an override replaces every - variable of its cell. A trailing `# ui: slider(min, max, step=, unit=, - label=)` comment (or `number(...)`, `toggle()`) on a parameter's line - chooses the control an editor offers; see the notebook runtime guide. The product — the `Part` or `Assembly` whose id is - the notebook id — is made in the last cell. A notebook that describes a part family names the - product `scad.notebook_id()`, and each use gives the member its id + (`sca run --set`) or a failure points at one feature. (Each block binds a new + name because a notebook name has one defining cell: `notebook-cell-design.md`, + fact 3.) +- A notebook that describes a part family names the product + `scad.notebook_id()`, and each use gives the member its id (`scad.use("link_bar.py", id="crank", ...)`). -- Imports from local modules go in the cell that uses them, or in the setup - cell; either way, editing the module invalidates the notebook's cache. ## Library parts: the `@scad.part` decorator @@ -162,8 +149,8 @@ the source itself: - `tolerance_profile` — kernel tolerance fingerprint (default `'simplecad-default'`). -A builder never caches; each call builds afresh. Inside a notebook the -calling cell is cached, and editing the module invalidates it. +A builder never caches; each call builds afresh. How the calling cell is cached +is in `docs/guides/notebook-composition-and-cache.md`. **Delivered sources state the anchor explicitly.** A translator-emitted or standalone `.ftc.py` passes `project_root=Path(__file__).parent` diff --git a/docs/skill/references/discipline/geometric-validation.md b/docs/skill/references/discipline/geometric-validation.md index e9724db3..e3e0fe77 100644 --- a/docs/skill/references/discipline/geometric-validation.md +++ b/docs/skill/references/discipline/geometric-validation.md @@ -3,6 +3,8 @@ Deterministic checks decide pass/fail; scale them to the specification. Every user-specified dimension requires a targeted measurement. +**Read when:** you write or run checks on a model: validity and volume gates, selection evidence before a fillet or chamfer, spec-driven measurements, replay, or what to report as having run. + ## Evidence labels - **SDK contract:** `Solid.get_volume()` returns a volume measurement; @@ -133,6 +135,8 @@ check. 3. A failed check closes by changing the design, or by changing the check's definition with the user — never by weakening the check or deleting the interfering material out of the verification body. + **Unattended:** redefine the check yourself, in the open: write the old and the new definition and why in the + report (`discipline/operating-modes.md`), and never weaken it silently. 4. Convert review feedback into a predicate before implementing it: "it occludes the hole in the render" -> does measured material intersect the hole cylinder? Run the cheap experiment that diff --git a/docs/skill/references/discipline/manufacturing-boundaries.md b/docs/skill/references/discipline/manufacturing-boundaries.md index 0dda96f0..f187c789 100644 --- a/docs/skill/references/discipline/manufacturing-boundaries.md +++ b/docs/skill/references/discipline/manufacturing-boundaries.md @@ -1,5 +1,7 @@ # Discipline: Manufacturing Boundaries +**Read when:** you apply default dimensions, or the request touches manufacturability, tolerance, strength or process claims. + What geometric modeling may and may not conclude about manufacturing. Numeric defaults in this document are first-pass design heuristics, not SDK defaults. @@ -9,6 +11,7 @@ defaults in this document are first-pass design heuristics, not SDK defaults. boundaries, and tolerance-analysis APIs. - **Modeling guidance:** wall thickness, fillet, clearance-hole, and backlash starting values. They require user confirmation or an explicit assumption. + **Unattended:** record the value as an assumption in the brief's ledger (`discipline/operating-modes.md`). ## Defaults are first-pass, not claims diff --git a/docs/skill/references/discipline/mechanical-modeling.md b/docs/skill/references/discipline/mechanical-modeling.md index dc5de37d..22b7b4e5 100644 --- a/docs/skill/references/discipline/mechanical-modeling.md +++ b/docs/skill/references/discipline/mechanical-modeling.md @@ -1,5 +1,7 @@ # Discipline: Mechanical Modeling +**Read when:** you choose a construction strategy or decompose a mechanical part or product before selecting SimpleCADAPI operations. + How to choose a construction strategy before selecting SimpleCADAPI operations. This document separates SDK contracts from mechanical design guidance. diff --git a/docs/skill/references/discipline/notebook-cell-design.md b/docs/skill/references/discipline/notebook-cell-design.md index cd39c4fc..1ccae00c 100644 --- a/docs/skill/references/discipline/notebook-cell-design.md +++ b/docs/skill/references/discipline/notebook-cell-design.md @@ -1,7 +1,7 @@ # Discipline: Notebook Cell Design -Where the cell boundaries go, and when a number is a `scad.var`. Read it with -`feature-tree-convention.md`: that file says what a feature block is and how it +Where the cell boundaries go, and when a number is a `scad.var`. **Read when:** you cut a notebook into cells, split or merge cells, or decide whether a number is a `scad.var`. +Read it with `feature-tree-convention.md`: that file says what a feature block is and how it is headed; this one says how to cut a notebook into cells and why. Every rule carries its reason and the symptom you see when you break it, so you can decide the cases the rules do not name. @@ -49,7 +49,9 @@ Fillets, chamfers, shells, large booleans, patterns: follow cached, and you retry only that cell. **C4. Merge cells only for things that are cheap and always edited together** -— a sketch and the extrusion of that same sketch are one feature anyway. Do +— a sketch and the extrusion of that same sketch are one feature anyway. Several +small blocks that always change together may share one cell, rebinding the body inside it; the cell returns the body +under the last block's slug, and they are cached and re-run as one unit, so keep this the exception. Do not merge two features to save a cell. *Why:* a cell costs one cache lookup, which is small; a merged cell re-runs both features on every edit, and a failure names neither. @@ -61,8 +63,8 @@ that, and returning it clutters the namespace with names that are not part of the feature tree. **C6. Whatever another cell needs is public, returned, and named for what it -is.** `base_plate`, not `body` or `shape2`; the slug of the block in -snake_case. *Why:* every top-level name has one defining cell (fact 3), and +is.** `base_plate`, not `body` or `shape2`; the naming rule (the block's slug in +snake_case) is in `feature-tree-convention.md`. *Why:* every top-level name has one defining cell (fact 3), and the names are the feature tree that a person and the next edit read. **C7. Do not change a value you received.** `apply_tag(...)`, @@ -76,7 +78,10 @@ disk copy instead, so a cell that tags what it received costs you the speed-up of the cell it tagged. **C8. Parameters live in their own cell(s) at the top, and hold no geometry.** -Group them by meaning (`# ---- params: flange ----`). *Why:* each reader +Group them by meaning (`# ---- params: flange ----`), because an override replaces every +variable of its cell. A trailing `# ui: slider(min, max, step=, unit=, label=)` +comment (or `number(...)`, `toggle()`) on a parameter's line chooses the control an +editor offers (`docs/guides/notebook-runtime.md`). *Why:* each reader depends only on the parameters it reads, so many parameters in one cell cost nothing; a geometry line in the params cell would re-run with every parameter change. @@ -98,6 +103,10 @@ name 'RADIUS' is not defined` until `base` is replaced too. Replace or delete the cells that read a name before, or in the same step as, the cell that defines it; when no editor is running, write the whole file once. +**C12. Import local modules in the cell that uses them, or in the setup cell.** +Either way, editing the module invalidates the notebook's cache +(`docs/guides/notebook-composition-and-cache.md`). + ## `Var` or a plain value A `scad.var(name, default, unit=..., comment=..., tolerance=...)` is a named diff --git a/docs/skill/references/discipline/operating-modes.md b/docs/skill/references/discipline/operating-modes.md new file mode 100644 index 00000000..3985afde --- /dev/null +++ b/docs/skill/references/discipline/operating-modes.md @@ -0,0 +1,68 @@ +# Discipline: Operating Modes + +**Read when:** at the start of every task (the router sends you here), and again whenever a gate says to ask or to wait +for a person. + +Every workflow runs in one of two modes. The mode changes who decides, never what is checked. + +## The two modes + +- **Interactive.** A person is in the loop (a conversation, or the editor open). You may ask questions, and a consent + gate waits for them. +- **Unattended.** No person will answer (a service, a batch run, a job queue). You never ask and never wait. Everything + a person would have decided, you decide from evidence and record. + +## Which mode am I in + +Decide once, at the start. The first line that matches wins: + +1. The task or the runtime instructions say the run is unattended, forbid questions, or say nobody will answer + ("unattended", "do not ask questions", "make evidence-based assumptions instead of asking") → **unattended**. +2. A person is talking to you, or has the editor open (`sca edit`) → **interactive**. +3. You have no tool for asking a question → **unattended**. +4. Otherwise → **interactive**. + +Write the result as the first line of the brief: `Mode: unattended (the task says the run is unattended)`. +Do not switch to interactive because a step is hard. In unattended mode the only way to reach a person is the report. + +## What never changes + +- A gate binds an artifact: it passes only when the artifact exists (printed facts, measurements, named views, filled + brief fields). A feeling of success is not a gate result, in either mode. +- A failed check closes by changing the design, or by redefining the check in the open. Never by weakening or deleting it. +- No claim of strength, fatigue, thermal behaviour, tolerance compliance or regulatory fitness without the analysis run. +- Every modelling rule (feature tree, QL selection, keyword arguments, cells and `Var`) is the same. +- Host-specific blocks about asking tools and plan tools are for interactive runs; in unattended mode skip the ones that + ask a person. + +## What changes: each gate and its replacement + +| In interactive mode | In unattended mode | +| --- | --- | +| An **Ask-or-Record** item: ask once, batched | **Record it.** Choose the value the evidence supports and write it in the brief's assumption ledger: `value / evidence / how to change it / the check that would show it wrong` | +| A **consent gate** (⛔: the person approves the brief or the plan) | A **self-review gate.** Re-read the brief against the request and the evidence, write one paragraph of decision with the open risks, and go on only when every required field is filled (a recorded assumption fills a field; an empty field does not) | +| A choice between design directions | Take the one that meets the stated requirements and is easier to change later; record the other in the plan | +| A missing prerequisite (file, tool, input): name it and stop that route | Record the blocker in the report, do the strongest work the available evidence supports (say which parts it supports), and go on. Never invent the missing input | +| An independent review (isolated reviewer, second pair of eyes) | A fresh-context check of your own against a written checklist, quoting what you observed; use a subagent if the host offers one. If neither is possible, the report says the review was not independent | +| Asking the person to look at a render | Look at it yourself: name the views, what you looked for, and quote the observation | +| Conflicting information | Measured evidence over prose, prose over inference; record the conflict and the choice | +| A closing message to the person | The report (below) | + +## The report (unattended) + +The deliverable that replaces every conversation. Name and place come from the task; the default is `report.md` next to +the source. It lists: + +- the assumptions, each as in the ledger above; +- what was reconstructed, what was omitted, and what is uncertain (with the evidence tier of each statement); +- the checks that ran with their results, and the checks that were not run, with the reason; +- any review that was not independent; +- the commands that reproduce the result. + +## Stopping + +End the run only after the source runs and you have inspected its result and written the report. The service that +receives the run validates independently; your closing text approves nothing. + +If the same check fails twice for the same cause, change the design or redefine the check in the report: do not retry the +same thing (`failure-and-repair.md`). diff --git a/docs/skill/references/discipline/requirement-and-cad-brief.md b/docs/skill/references/discipline/requirement-and-cad-brief.md index fbdc1b8e..98f2ab89 100644 --- a/docs/skill/references/discipline/requirement-and-cad-brief.md +++ b/docs/skill/references/discipline/requirement-and-cad-brief.md @@ -5,6 +5,9 @@ modeling brief. The operational steps live in `../domains/requirement-refinement.md`; this file carries reasoning and evidence-handling guidance, not automatic SDK behavior. +**Read when:** Role 1 (or any brief-writing step) starts: prose, images or drawings are being turned into a brief, or +evidence conflicts. + ## Every modality funnels into one brief Prose, images, drawings, and conflicts all reduce to the same artifact: a @@ -45,6 +48,9 @@ A drawing is a dimensioned contract: Success for a drawing-driven model: every drawing dimension is either verified by a measurement after generation, or explicitly reported as not verified. +**Unattended:** where this page says to flag or ask the user, record the conflict, the choice and the evidence in the +brief's assumption ledger instead (`discipline/operating-modes.md`); the checks and the brief quality gate are unchanged. + ## Assumptions are load-bearing only when reported An assumption silently applied is a future defect. Each one in the brief @@ -70,6 +76,9 @@ review invalidates an assumption, revisit every feature derived from it in place — a rebuild that re-encodes the same unchecked assumption reproduces the same rejection. +**Unattended:** the one question that would be asked is recorded as an assumption (value, evidence, how to change it, the +check that would show it wrong); nothing is asked. + ## Brief quality gate The brief is ready when it defines: source path(s), units, coordinate diff --git a/docs/skill/references/discipline/verifier-scripts.md b/docs/skill/references/discipline/verifier-scripts.md index 0b5d6c88..ec598f67 100644 --- a/docs/skill/references/discipline/verifier-scripts.md +++ b/docs/skill/references/discipline/verifier-scripts.md @@ -1,5 +1,8 @@ # Discipline: Verifier Scripts +**Read when:** Role 3 writes the executable check for a stage, or any task needs a script that measures a finished model +against the specification. + A verifier script measures a finished model against the specification and exits non-zero when a requirement fails. It is separate from the modeling script: it imports the model, measures it independently, and prints one line per check. diff --git a/docs/skill/references/docs/guides/README.md b/docs/skill/references/docs/guides/README.md index c6e21cb0..6c7ab9b9 100644 --- a/docs/skill/references/docs/guides/README.md +++ b/docs/skill/references/docs/guides/README.md @@ -1,14 +1,14 @@ # Engineering Guides -- [STEP BREP Reverse Engineering](step-brep-reverse-engineering.md): full workflow from STEP inspection, geometric fingerprint analysis, feature-tree inference, and candidate iteration to geometric point-set, BREP topology, parameter history, and model replay acceptance. +- [STEP BREP Reverse Engineering](../../inspect/brep-reverse-engineering.md): full workflow from STEP inspection, geometric fingerprint analysis, feature-tree inference, and candidate iteration to geometric point-set, BREP topology, parameter history, and model replay acceptance. - [Reconstruction Agent Test Prompt](reconstruction-agent-test-prompt.md): compact, versioned run contract for controlled STEP reconstruction trials. - [Reconstruction Agent Strategy](reconstruction-agent-strategy.md): advisory inspection, feature-selection, Sketch, Boolean, and iteration tactics. -- [Notebook Runtime and Product Build Workflow](notebook-runtime.md): - models as marimo notebooks, `sca run`, `scad.use` composition, the cell - cache, `@part`/`@assemble` library parts, `.scadpkg` delivery, - FreeCAD/AP242 targets, and optional Gmsh volume meshing. +- [Notebook Runtime](notebook-runtime.md): models as marimo notebooks, `sca run`, parameters, the editor. +- [Notebook Composition and Cache](notebook-composition-and-cache.md): `scad.use` composition, part families, the cell + cache, `@part`/`@assemble` library parts. +- [Product Export](product-export.md): `.scadpkg` delivery, FreeCAD/AP242 targets, STL/OBJ, optional Gmsh volume meshing. The corresponding runnable module and examples live in: diff --git a/docs/skill/references/docs/guides/notebook-composition-and-cache.md b/docs/skill/references/docs/guides/notebook-composition-and-cache.md new file mode 100644 index 00000000..07a3dcad --- /dev/null +++ b/docs/skill/references/docs/guides/notebook-composition-and-cache.md @@ -0,0 +1,141 @@ +# Notebook Composition and the Cell Cache + +**Read when:** a notebook uses another notebook (`scad.use`), one notebook describes a part family, a run is unexpectedly cached or stale, or a reusable part is built with `@scad.part` / `@scad.assemble`. Running a notebook and parameters are in `notebook-runtime.md`. + +## Composing notebooks + +`scad.use(path, **overrides)` runs another notebook and returns its product, +ready to be a component of an assembly. A relative path is relative to the +calling notebook; keyword arguments override the child's top-level variables. + +```python +@app.cell +def _(): + bracket = scad.use("bracket.py", width=24.0) + return (bracket,) + + +@app.cell +def _(bracket): + rig = scad.make_assembly_rassembly(assembly_id="rig", name="Rig") + rig = scad.add_component_rassembly( + assembly=rig, item=bracket, component_id="bracket", + placement=scad.identity_placement_rplacement(), + ) + return (rig,) +``` + +The child runs with its own cell cache, and the calling notebook's cache +depends on the child from then on. + +### Part families + +One notebook may describe several parts that differ only in their +parameters — the links of a linkage, the planet gears of two stages. Name the +product with `scad.notebook_id()` instead of a literal: + +```python +# link_bar.py — [tool.simplecadapi] id = "link_bar" +@app.cell +def _(web): + link_bar = scad.Part(part_id=scad.notebook_id(), body=web) + return (link_bar,) +``` + +and give each member an id of its own where it is used: + +```python +@app.cell +def _(): + crank = scad.use("link_bar.py", id="crank", center_distance=40.0) + rocker = scad.use("link_bar.py", id="rocker", center_distance=90.0) + return crank, rocker +``` + +Each id is one definition, with its own content hash and its own cell cache. +Used twice under one id with different parameters, the two products would be +two definitions with the same id, which an assembly rejects +(`reference_identity_conflict`). Run alone, the notebook builds the member +named in its header. + +## The cell cache + +Every cell except the setup cell is cached, in headless runs and in the +editor's kernel alike (what one computed the other restores). marimo keys a +cell's entry by its code and what it reads; SimpleCAD adds a digest of what +the cells cannot show, **per cell**, over the files *that cell* depends on: + +- **local modules** — Python files under the notebook's directory, found from + the `import` statements (setup cell included), and the local modules those + import in turn. A cell depends on a module when it reads a name an `import` + bound to it, or calls a function the notebook defines (an `@app.function` + cell, a `def` in the setup cell) that does; +- **everything the cells it reads from depend on**: marimo keys a cell that + reads an object (a solid, a part) by the code of the cells that made it, so + it cannot see a module that changed upstream; +- **child notebooks** run with `scad.use` (and what they depend on), for the + cells that call `use` and those that read what they make; +- the files listed in `inputs`, for every cell; +- the SimpleCAD, OpenCASCADE, and marimo versions. + +So editing `dimensions.py` recomputes the cells that read the dimensions (and +what is made from them) and restores the rest, and going back to an earlier +value restores everything. A run keeps the entries of its own digests and of +the previous run's. Local modules are imported afresh when their files changed +since the last run in the same process. + +- Imports through `importlib` or `__import__` are not seen; import local + modules with `import` statements, or list the files in `inputs`. +- Values that cannot be pickled cannot be restored: the run then executes the + whole notebook without the cache. Keep such values (locks, open files, + handles) out of top-level variables. +- The editor kernel does not cache the cells that run child notebooks (only a + headless run records what they depend on; the children themselves are + cached), and keys the cells downstream of a value an editor saved by that + value. `SCA_NO_KERNEL_CACHE=1` turns the kernel's cache off. +- The state lives in `__marimo__/simplecad///` + (`deps.json` and `cache//`), one directory per part-family member. It + is generated state: do not commit `__marimo__/`. Entries are unsigned unless + the `cryptography` package is installed; treat the directory as trusted local + files. + +**In the marimo editor, leave `[tool.marimo.runtime] cache_cells` off.** That +is marimo's own cache: it does not know these digests, so after a local module +or child notebook changes it would restore stale values. Under `sca edit` the +SimpleCAD cache above is already in use. + +## Library parts: `@scad.part` and `@scad.assemble` + +Reusable parts belong in plain `.py` modules next to the notebooks, built by +decorated builders. A call builds the part in a session of its own and returns +a `PartBuildResult(value, definition, feature_graph)`; `@scad.assemble` +returns an `AssemblyBuildResult` with the same fields. Calls may happen inside +a notebook cell or another builder: the built part enters the calling +recording as an external definition. + +```python +# lib/plates.py +import simplecadapi as scad + + +@scad.part(id="mounting_plate", revision="1.0.0") +def build_plate(width: float = 30.0) -> scad.Part: + body = scad.make_box_rsolid(width=width, height=20.0, depth=3.0) + return scad.make_part_rpart(part_id="mounting_plate", body=body) +``` + +```python +@app.cell +def _(): + from lib.plates import build_plate + + plate = build_plate(width=40.0).value + return (plate,) +``` + +The builders do not cache; inside a notebook the calling cell is cached, and +editing `lib/plates.py` invalidates it through *D*. Declare every non-Python +file a builder reads with `scad.file_input(...)`; its content snapshot is +recorded in the definition. `@scad.assemble(definitions=...)` names the +external part and assembly definitions an assembly references; its assembly +is solved strictly. diff --git a/docs/skill/references/docs/guides/notebook-runtime.md b/docs/skill/references/docs/guides/notebook-runtime.md index 51710d2e..fa182137 100644 --- a/docs/skill/references/docs/guides/notebook-runtime.md +++ b/docs/skill/references/docs/guides/notebook-runtime.md @@ -86,7 +86,7 @@ if __name__ == "__main__": given another id (`run_notebook(..., id=...)`, `scad.use(..., id=...)`, `sca run --id`); cells read the id of their run with `scad.notebook_id()` — see - [Part families](#part-families). + [Part families](notebook-composition-and-cache.md#part-families). - **Parameters** are ordinary top-level variables. Overriding one replaces it, and the cell that defines it does not run, so all variables of that cell must be overridden together. Group parameters into cells by what changes @@ -219,225 +219,7 @@ The editor and a coding agent share the notebook file: and can run everything above it; `@app.function` cells are shown read-only (edit them in the file). -## Composing notebooks +## Composing notebooks, the cell cache, library parts, export -`scad.use(path, **overrides)` runs another notebook and returns its product, -ready to be a component of an assembly. A relative path is relative to the -calling notebook; keyword arguments override the child's top-level variables. - -```python -@app.cell -def _(): - bracket = scad.use("bracket.py", width=24.0) - return (bracket,) - - -@app.cell -def _(bracket): - rig = scad.make_assembly_rassembly(assembly_id="rig", name="Rig") - rig = scad.add_component_rassembly( - assembly=rig, item=bracket, component_id="bracket", - placement=scad.identity_placement_rplacement(), - ) - return (rig,) -``` - -The child runs with its own cell cache, and the calling notebook's cache -depends on the child from then on. - -### Part families - -One notebook may describe several parts that differ only in their -parameters — the links of a linkage, the planet gears of two stages. Name the -product with `scad.notebook_id()` instead of a literal: - -```python -# link_bar.py — [tool.simplecadapi] id = "link_bar" -@app.cell -def _(web): - link_bar = scad.Part(part_id=scad.notebook_id(), body=web) - return (link_bar,) -``` - -and give each member an id of its own where it is used: - -```python -@app.cell -def _(): - crank = scad.use("link_bar.py", id="crank", center_distance=40.0) - rocker = scad.use("link_bar.py", id="rocker", center_distance=90.0) - return crank, rocker -``` - -Each id is one definition, with its own content hash and its own cell cache. -Used twice under one id with different parameters, the two products would be -two definitions with the same id, which an assembly rejects -(`reference_identity_conflict`). Run alone, the notebook builds the member -named in its header. - -## The cell cache - -Every cell except the setup cell is cached, in headless runs and in the -editor's kernel alike (what one computed the other restores). marimo keys a -cell's entry by its code and what it reads; SimpleCAD adds a digest of what -the cells cannot show, **per cell**, over the files *that cell* depends on: - -- **local modules** — Python files under the notebook's directory, found from - the `import` statements (setup cell included), and the local modules those - import in turn. A cell depends on a module when it reads a name an `import` - bound to it, or calls a function the notebook defines (an `@app.function` - cell, a `def` in the setup cell) that does; -- **everything the cells it reads from depend on**: marimo keys a cell that - reads an object (a solid, a part) by the code of the cells that made it, so - it cannot see a module that changed upstream; -- **child notebooks** run with `scad.use` (and what they depend on), for the - cells that call `use` and those that read what they make; -- the files listed in `inputs`, for every cell; -- the SimpleCAD, OpenCASCADE, and marimo versions. - -So editing `dimensions.py` recomputes the cells that read the dimensions (and -what is made from them) and restores the rest, and going back to an earlier -value restores everything. A run keeps the entries of its own digests and of -the previous run's. Local modules are imported afresh when their files changed -since the last run in the same process. - -- Imports through `importlib` or `__import__` are not seen; import local - modules with `import` statements, or list the files in `inputs`. -- Values that cannot be pickled cannot be restored: the run then executes the - whole notebook without the cache. Keep such values (locks, open files, - handles) out of top-level variables. -- The editor kernel does not cache the cells that run child notebooks (only a - headless run records what they depend on; the children themselves are - cached), and keys the cells downstream of a value an editor saved by that - value. `SCA_NO_KERNEL_CACHE=1` turns the kernel's cache off. -- The state lives in `__marimo__/simplecad///` - (`deps.json` and `cache//`), one directory per part-family member. It - is generated state: do not commit `__marimo__/`. Entries are unsigned unless - the `cryptography` package is installed; treat the directory as trusted local - files. - -**In the marimo editor, leave `[tool.marimo.runtime] cache_cells` off.** That -is marimo's own cache: it does not know these digests, so after a local module -or child notebook changes it would restore stale values. Under `sca edit` the -SimpleCAD cache above is already in use. - -## Library parts: `@scad.part` and `@scad.assemble` - -Reusable parts belong in plain `.py` modules next to the notebooks, built by -decorated builders. A call builds the part in a session of its own and returns -a `PartBuildResult(value, definition, feature_graph)`; `@scad.assemble` -returns an `AssemblyBuildResult` with the same fields. Calls may happen inside -a notebook cell or another builder: the built part enters the calling -recording as an external definition. - -```python -# lib/plates.py -import simplecadapi as scad - - -@scad.part(id="mounting_plate", revision="1.0.0") -def build_plate(width: float = 30.0) -> scad.Part: - body = scad.make_box_rsolid(width=width, height=20.0, depth=3.0) - return scad.make_part_rpart(part_id="mounting_plate", body=body) -``` - -```python -@app.cell -def _(): - from lib.plates import build_plate - - plate = build_plate(width=40.0).value - return (plate,) -``` - -The builders do not cache; inside a notebook the calling cell is cached, and -editing `lib/plates.py` invalidates it through *D*. Declare every non-Python -file a builder reads with `scad.file_input(...)`; its content snapshot is -recorded in the definition. `@scad.assemble(definitions=...)` names the -external part and assembly definitions an assembly references; its assembly -is solved strictly. - -## Product package export - -A notebook's product, or a builder's result, is written as one canonical -product package: - -```python -scad.capture(run.definition, "out/bracket.scadpkg") -scad.capture(build_plate(), "out/plate.scadpkg") - -root = scad.load_product_package("out/bracket.scadpkg") -rebuilt = scad.materialize_definition(root) -``` - -Use `build_product_package(...)`, `encode_product_package(...)`, -`read_product_package(...)`, and `load_product_package(...)` only for explicit -in-memory package handling. `validate_product_package(...)` verifies every object -hash and size, the complete recursive definition graph, cycle/depth limits, and -rejects missing or unreferenced objects. Use `export_part_definition(...)` or -`export_assembly_definition(...)` only for low-level definition exchange or -inspection; neither is the product delivery format. - -## Downstream CAD and mesh targets - -Keep `.scadpkg` as the canonical SimpleCAD delivery artifact. Choose a -downstream target from the consumer contract: - -| Target | Use when | Preserved contract | Boundary | -| --- | --- | --- | --- | -| `.scadpkg` | Rebuild, replay, or further SimpleCAD tooling is required | Complete durable definition closure, feature graphs, source snapshots, topology, connectors, constraints, and materials | SimpleCAD-specific archive | -| `.FCStd` | A FreeCAD user needs an editable native document | Definition-owned, dependency-first native feature graphs; final body links; repeated instances; nested assemblies; names; solved placements; materials; connectors; constraints; grounding; revision; and content hashes | Requires FreeCADCmd/FreeCAD | -| AP242 `.step` | Neutral CAD exchange or downstream OpenCASCADE tooling is required | Evaluated BREP, product hierarchy, shared definitions, occurrence names/placements, materials, colors, density, and named SimpleCAD property payloads | Canonical feature history is not reconstructed as AP242 features | -| Triangle `.obj` | Surface inspection or DCC exchange needs an indexed mesh | Direct OpenCASCADE tessellation of evaluated BREP with shared vertices and oriented triangles | Evaluated surface mesh only; no CAD hierarchy, feature semantics, quads, or materials | -| Binary `.stl` | Additive manufacturing or triangle-only consumers | The same direct OpenCASCADE BREP triangles | Facet soup only; no shared vertices, CAD hierarchy, feature semantics, or materials | - -```python -package_path = "out/product.scadpkg" -scad.capture(run.definition, package_path) - -fcstd_path = scad.translator.freecad_translator.translate_product_package_to_fcstd( - package_path, - "out/product.FCStd", -) -step_report = scad.exporter.export_product_package_to_step( - package_path, - "out/product.step", -) -stl_report = scad.exporter.export_product_package_to_stl( - package_path, - "out/product.stl", -) -obj_report = scad.exporter.export_product_package_to_obj( - package_path, - "out/product.obj", -) -print(step_report.definition_ids, step_report.occurrence_count) -print(stl_report.solid_count, stl_report.triangle_count) -print(obj_report.vertex_count, obj_report.triangle_count) -``` - -STL and OBJ use the same direct OpenCASCADE tessellation and parameters. Set -`linear_deflection` to the maximum chordal deviation in product units and -`angular_deflection_degrees` to the curved-surface angular limit. Both formats -contain oriented triangles; OBJ preserves shared vertex indices while binary STL -stores each facet independently. No remeshing or optional dependency is involved. - -AP242 definition and occurrence metadata is stored in one standard -`PROPERTY_DEFINITION_REPRESENTATION`. Its named -`DESCRIPTIVE_REPRESENTATION_ITEM` records use -`SimpleCAD:definition::` and -`SimpleCAD:occurrence::`. Each description -is canonical sorted JSON containing durable IDs, revision, content hash, -connectors, constraints, grounding, name, and solved placement. -`ProductSTEPExportReport` reports the schema, definition IDs, occurrence -count, material IDs, metadata item count, and explicit limitations. - -For tetrahedral meshing, run the split example directly in dependency order: -the model is the `bracket.py` notebook, `export_step.py` captures its product -to `.scadpkg` (through `bracket_package.py`) and exports AP242 STEP, and -`export_fem_mesh.py` imports that STEP with Gmsh's OpenCASCADE kernel. The FEM -stage rejects imports with no 3D volumes, generates a dimension-3 mesh, writes -`.msh`, and always finalizes Gmsh. Install and invoke the optional dependency -with `uv run --extra gmsh python -examples/ap242_gmsh_volume_mesh/export_fem_mesh.py`; Gmsh is never imported -by the core SDK or product exporters. +- Composing notebooks (`scad.use`), part families (`scad.notebook_id()`), the cell cache, and library parts (`@scad.part`, `@scad.assemble`): `notebook-composition-and-cache.md`. +- Product package export (`scad.capture`), downstream targets (`.FCStd`, STEP, STL, OBJ) and the Gmsh meshing example: `product-export.md`. diff --git a/docs/skill/references/docs/guides/product-export.md b/docs/skill/references/docs/guides/product-export.md new file mode 100644 index 00000000..bd2cfddf --- /dev/null +++ b/docs/skill/references/docs/guides/product-export.md @@ -0,0 +1,88 @@ +# Product Package Export and Downstream Targets + +**Read when:** a notebook's product must be written as a `.scadpkg` or delivered to FreeCAD, STEP, STL, OBJ or a mesher. Running a notebook and parameters are in `notebook-runtime.md`. + +## Product package export + +A notebook's product, or a builder's result, is written as one canonical +product package: + +```python +scad.capture(run.definition, "out/bracket.scadpkg") +scad.capture(build_plate(), "out/plate.scadpkg") + +root = scad.load_product_package("out/bracket.scadpkg") +rebuilt = scad.materialize_definition(root) +``` + +Use `build_product_package(...)`, `encode_product_package(...)`, +`read_product_package(...)`, and `load_product_package(...)` only for explicit +in-memory package handling. `validate_product_package(...)` verifies every object +hash and size, the complete recursive definition graph, cycle/depth limits, and +rejects missing or unreferenced objects. Use `export_part_definition(...)` or +`export_assembly_definition(...)` only for low-level definition exchange or +inspection; neither is the product delivery format. + +## Downstream CAD and mesh targets + +Keep `.scadpkg` as the canonical SimpleCAD delivery artifact. Choose a +downstream target from the consumer contract: + +| Target | Use when | Preserved contract | Boundary | +| --- | --- | --- | --- | +| `.scadpkg` | Rebuild, replay, or further SimpleCAD tooling is required | Complete durable definition closure, feature graphs, source snapshots, topology, connectors, constraints, and materials | SimpleCAD-specific archive | +| `.FCStd` | A FreeCAD user needs an editable native document | Definition-owned, dependency-first native feature graphs; final body links; repeated instances; nested assemblies; names; solved placements; materials; connectors; constraints; grounding; revision; and content hashes | Requires FreeCADCmd/FreeCAD | +| AP242 `.step` | Neutral CAD exchange or downstream OpenCASCADE tooling is required | Evaluated BREP, product hierarchy, shared definitions, occurrence names/placements, materials, colors, density, and named SimpleCAD property payloads | Canonical feature history is not reconstructed as AP242 features | +| Triangle `.obj` | Surface inspection or DCC exchange needs an indexed mesh | Direct OpenCASCADE tessellation of evaluated BREP with shared vertices and oriented triangles | Evaluated surface mesh only; no CAD hierarchy, feature semantics, quads, or materials | +| Binary `.stl` | Additive manufacturing or triangle-only consumers | The same direct OpenCASCADE BREP triangles | Facet soup only; no shared vertices, CAD hierarchy, feature semantics, or materials | + +```python +package_path = "out/product.scadpkg" +scad.capture(run.definition, package_path) + +fcstd_path = scad.translator.freecad_translator.translate_product_package_to_fcstd( + package_path, + "out/product.FCStd", +) +step_report = scad.exporter.export_product_package_to_step( + package_path, + "out/product.step", +) +stl_report = scad.exporter.export_product_package_to_stl( + package_path, + "out/product.stl", +) +obj_report = scad.exporter.export_product_package_to_obj( + package_path, + "out/product.obj", +) +print(step_report.definition_ids, step_report.occurrence_count) +print(stl_report.solid_count, stl_report.triangle_count) +print(obj_report.vertex_count, obj_report.triangle_count) +``` + +STL and OBJ use the same direct OpenCASCADE tessellation and parameters. Set +`linear_deflection` to the maximum chordal deviation in product units and +`angular_deflection_degrees` to the curved-surface angular limit. Both formats +contain oriented triangles; OBJ preserves shared vertex indices while binary STL +stores each facet independently. No remeshing or optional dependency is involved. + +AP242 definition and occurrence metadata is stored in one standard +`PROPERTY_DEFINITION_REPRESENTATION`. Its named +`DESCRIPTIVE_REPRESENTATION_ITEM` records use +`SimpleCAD:definition::` and +`SimpleCAD:occurrence::`. Each description +is canonical sorted JSON containing durable IDs, revision, content hash, +connectors, constraints, grounding, name, and solved placement. +`ProductSTEPExportReport` reports the schema, definition IDs, occurrence +count, material IDs, metadata item count, and explicit limitations. + +For tetrahedral meshing, run the split example directly in dependency order: +the model is the `bracket.py` notebook, `export_step.py` captures its product +to `.scadpkg` (through `bracket_package.py`) and exports AP242 STEP, and +`export_fem_mesh.py` imports that STEP with Gmsh's OpenCASCADE kernel. The FEM +stage rejects imports with no 3D volumes, generates a dimension-3 mesh, writes +`.msh`, and always finalizes Gmsh. Install and invoke the optional dependency +with `uv run --extra gmsh python +examples/ap242_gmsh_volume_mesh/export_fem_mesh.py`; Gmsh is never imported +by the core SDK or product exporters. diff --git a/docs/skill/references/domains/addon-development.md b/docs/skill/references/domains/addon-development.md index bc2370cb..e2a80284 100644 --- a/docs/skill/references/domains/addon-development.md +++ b/docs/skill/references/domains/addon-development.md @@ -4,6 +4,8 @@ Author, install, and manage SimpleCADAPI addons: third-party packages that add non-modeling capabilities — simulation, analysis, downstream tooling — by consuming `.scadpkg` product packages. +**Read when:** you create, publish, install, update or remove a SimpleCADAPI addon (`sca-addon.toml`), or decide whether a capability is an addon or SDK work. + ## Use when - Creating or publishing an addon (a repository with `sca-addon.toml` and diff --git a/docs/skill/references/domains/assembly-and-product.md b/docs/skill/references/domains/assembly-and-product.md index 649e5361..7f314743 100644 --- a/docs/skill/references/domains/assembly-and-product.md +++ b/docs/skill/references/domains/assembly-and-product.md @@ -1,5 +1,7 @@ # Task Domain: Assembly and Product +**Read when:** the deliverable has more than one physical part, repeated instances, nested subassemblies, motion semantics, or a `.scadpkg` package (details under "Use when"). + Compose multi-part products with explicit placement, connectors, and declarative constraints; compose notebooks with `scad.use`, build durable definitions (notebook products, `@part`/`@assemble` library parts), and diff --git a/docs/skill/references/domains/drawing-inspection-evidence.md b/docs/skill/references/domains/drawing-inspection-evidence.md new file mode 100644 index 00000000..8f4dd3f3 --- /dev/null +++ b/docs/skill/references/domains/drawing-inspection-evidence.md @@ -0,0 +1,133 @@ +# Task Domain: Drawing Inspection Evidence (model sections and executable checks) + +**Read when:** a section view of the drawing must be verified against the rebuilt model, a DIM ledger row needs its +`measurement_contract`, or the roundtrip evidence (annotations, reviews, coverage) is built or checked. + +Companion of `domains/drawing-inspection.md` (the parsing SOP, tiers and report skeleton). Everything below feeds the +roundtrip in `workflows/drawing-reconstruction/roundtrip-acceptance.md`. + +## Model-section evidence group + +Cut the rebuilt model where the drawing cuts it; these functions take a live +shape (pass ``solid.wrapped``) or a STEP path and an explicit plane, and stay +factual — plane semantics belong to the caller: + +- `measure_model_section_rdimensions` — distinguish analytic circle geometry, + fitted-circle candidates, continuous geometry extrema/distances and sampled + approximations by each record's method and accuracy fields. A fitted circle's + residual is not a dimensional error bound. Section intersection tolerance is + not total measurement uncertainty. Unknown error bounds stay unknown. + Circle diagnostics include conditioning, angular coverage/max gap and both + RMS/max residual. Local arcs, open/gapped contours and nearly collinear + samples stay unverified; do not modify CAD dimensions from unreliable fits. +- `render_model_section_rpath` — annotated section render (45° hatch on + material only, dimension leaders with `dim_id` + measured value + sheet + value, mm scale bar). Fixed output: the section PNG and sidecar. The + renderer displays caller data, including wrong numbers; rendering success + does not prove measurement correctness. Formal evidence must use the bound + annotation builder and independent validator. The caller owns the comparison: put the render next to the drawing's own + viewport crop (same evidence directory) and judge — no composite is + generated for you. + +### Explicit section strategy and integrity evidence + +Result version **2.2** adds `section_strategy="auto"|"material"|"edges"`. +Auto uses material-face intersection for solids even if edge contours would +already be closed, and edge intersection for non-solid diagnostic inputs. +Material mode retains the actual edges and topological outer/inner wire roles +from the same intersection. Edges mode remains available; with formal checks +it also obtains an independent material-face reference. Environment variable +`SIMPLECADAPI_MATERIAL_SECTION` no longer selects the workflow strategy. +Archive both the requested strategy and the actual `contour_method`. + +Each formal DIM ledger row must reference the same independently reviewed +`section_checks` contract as its measurement. For a tube cut at z=10, an example +contract is: + +```json +{ + "expected_origin": [0, 0, 10], + "expected_normal": [0, 0, 1], + "expected_x_direction": [0, -1, 0], + "expected_y_direction": [1, 0, 0], + "expected_solid_count": 1, + "expected_face_count": 1, + "expected_hole_count": 1, + "material_points": [{"id": "wall", "point": [9, 0]}], + "void_points": [{"id": "bore", "point": [0, 0]}], + "point_tolerance_mm": 0.0000001, + "tolerance_basis": "Point-classification precision for this fixture", + "evidence": "Drawing view and independent feature-association review" +} +``` + +The example's points assume an outer radius of 10 and inner radius of 8; +choose actual points from the intended drawing geometry. Expected counts and +directions come from the drawing/confirmed requirements, never from copying the +current result. Points are section-local mm. Every material face needs an +interior material point; every hole needs an interior void point. Boundary or +indeterminate classifications stay pending. Empty void lists are allowed only +when no topological hole needs coverage; include points in any additional +drawing-defined open cavity or gap. The coordinate-frame direction comparison +uses the recorded numerical threshold 1e-10; point tolerance is not a dimensional +error bound. + +`section_validation` separately reports source validity/closure/solid count, +section closure, valid material faces and inner/outer rings, and each point's +classification against the original solid, material faces, and displayed +polygons. Formal rendering checks its own actual sampling density. Open or +incomplete sections may render as `diagnostic_only`, but cannot become formal +evidence. Missing section checks are not auto-filled from measured geometry. + +On failure, inspect the section definition, intersection and display first. +`section_definition`, `section_intersection` and `section_display` failures +do not justify changing the model. Only independent solid evidence, confirmed +against the drawing, can establish a model-geometry error. Mass, center of mass, +inertia and high-precision volume matching remain outside drawing-reconstruction +acceptance unless the user separately requests them. + +### Executable evidence checks + +Read the exact API pages for `inspect_drawing_coordinates_rreport`, +`assess_dimension_rverdict`, `build_section_annotation_rrecord`, +`validate_section_annotations_rreport`, and +`summarize_dimension_coverage_rreport`. The latter requires the complete expected +DIM list; omitted rows count as missing in every coverage column. + +Result version **2.1** requires an independent `measurement_contract` in each +ledger row. For example, a section's width is +`{"kind":"extent_width","definition":"axis_aligned_extent","direction":[1,0], +"point":null,"units":"mm","coordinate_space":"section_local"}`. +Height uses `extent_height` and `[0,1]`. Diameter uses `circle_diameter`, +minimum gap uses `minimum_boundary_gap`, both with null direction/point. +Directional material thickness uses `material_length_on_line`, a normalized +local direction and the specified local point. Geometry IDs bind the selected +contour; this separate contract binds what is measured on it. Missing contracts +cannot be inferred from whichever measurement happens to fall inside tolerance. + +The repository script `tools/verify_drawing_evidence.py --model part.step +--evidence evidence.json --out verification.json` independently remeasures and +reports all three gates. Its input contains `plane`, the complete `ledger`, +bound `annotations`, and `output_reviews` keyed by DIM ID. An output review +contains `layout_status: reviewed`, `association_status: verified` and independent +`evidence` references, plus the `dim_id`, `measurement_id`, `model_hash` and +`section_id` captured **when the image was reviewed**. Its `render_artifact` +contains absolute `image_path`, `sidecar_path`, and the reviewed +`image_sha256`, `sidecar_sha256`. The renderer records the image hash and each +annotation's content-derived ID in the sidecar. Validate both file hashes, +annotation content (including anchors and precision) and measurement identity. +Never replace old review IDs or hashes with current values during verification. +Old or missing identities require a new review of the actual current artifact. +Missing visual review remains pending even when numeric checks pass. + +Formal annotations also check the actual formatted value: the display step +must be no wider than the tolerance band, rounding error no larger than half +its width, and the displayed number itself inside the band. Zero-width bands +require exact representation. For 10.4 mm in [10.39,10.41], zero decimals are +rejected; one decimal also lacks the required resolution. Geometric conformance +continues to use the unrounded value and its uncertainty interval. + +An external total-error assessment must be tied to the exact `measurement_id` +and include `bound_mm`, `basis`, and `evidence`. These fields record a separate +metrology assessment; the SDK cannot establish its truth just by reading them. +Do not turn a fitted-circle residual or kernel edge tolerance into this bound. diff --git a/docs/skill/references/domains/drawing-inspection.md b/docs/skill/references/domains/drawing-inspection.md index 3845e0a8..2a6ea993 100644 --- a/docs/skill/references/domains/drawing-inspection.md +++ b/docs/skill/references/domains/drawing-inspection.md @@ -1,5 +1,8 @@ # Task Domain: Engineering-Drawing Inspection (Vector PDF) +**Read when:** a vector-PDF engineering drawing must be parsed into tiered evidence, or a rebuilt model is read back +for the roundtrip. + Extract traceable evidence from vector-PDF engineering drawings with the `simplecadapi.inspect.drawing` namespace: health probing, text-layer inventory, rendered viewports, and calibrated vector measurement. Everything @@ -92,85 +95,9 @@ Read the exact page under `references/docs/api/` for every API used: verdict) and `measure_drawing_rmeasurements` (point distances, stroke lengths, mm conversion through an accepted calibration). -## Model-section evidence group - -Cut the rebuilt model where the drawing cuts it; these functions take a live -shape (pass ``solid.wrapped``) or a STEP path and an explicit plane, and stay -factual — plane semantics belong to the caller: - -- `measure_model_section_rdimensions` — distinguish analytic circle geometry, - fitted-circle candidates, continuous geometry extrema/distances and sampled - approximations by each record's method and accuracy fields. A fitted circle's - residual is not a dimensional error bound. Section intersection tolerance is - not total measurement uncertainty. Unknown error bounds stay unknown. - Circle diagnostics include conditioning, angular coverage/max gap and both - RMS/max residual. Local arcs, open/gapped contours and nearly collinear - samples stay unverified; do not modify CAD dimensions from unreliable fits. -- `render_model_section_rpath` — annotated section render (45° hatch on - material only, dimension leaders with `dim_id` + measured value + sheet - value, mm scale bar). Fixed output: the section PNG and sidecar. The - renderer displays caller data, including wrong numbers; rendering success - does not prove measurement correctness. Formal evidence must use the bound - annotation builder and independent validator. The caller owns the comparison: put the render next to the drawing's own - viewport crop (same evidence directory) and judge — no composite is - generated for you. - -### Explicit section strategy and integrity evidence - -Result version **2.2** adds `section_strategy="auto"|"material"|"edges"`. -Auto uses material-face intersection for solids even if edge contours would -already be closed, and edge intersection for non-solid diagnostic inputs. -Material mode retains the actual edges and topological outer/inner wire roles -from the same intersection. Edges mode remains available; with formal checks -it also obtains an independent material-face reference. Environment variable -`SIMPLECADAPI_MATERIAL_SECTION` no longer selects the workflow strategy. -Archive both the requested strategy and the actual `contour_method`. - -Each formal DIM ledger row must reference the same independently reviewed -`section_checks` contract as its measurement. For a tube cut at z=10, an example -contract is: - -```json -{ - "expected_origin": [0, 0, 10], - "expected_normal": [0, 0, 1], - "expected_x_direction": [0, -1, 0], - "expected_y_direction": [1, 0, 0], - "expected_solid_count": 1, - "expected_face_count": 1, - "expected_hole_count": 1, - "material_points": [{"id": "wall", "point": [9, 0]}], - "void_points": [{"id": "bore", "point": [0, 0]}], - "point_tolerance_mm": 0.0000001, - "tolerance_basis": "Point-classification precision for this fixture", - "evidence": "Drawing view and independent feature-association review" -} -``` - -The example's points assume an outer radius of 10 and inner radius of 8; -choose actual points from the intended drawing geometry. Expected counts and -directions come from the drawing/confirmed requirements, never from copying the -current result. Points are section-local mm. Every material face needs an -interior material point; every hole needs an interior void point. Boundary or -indeterminate classifications stay pending. Empty void lists are allowed only -when no topological hole needs coverage; include points in any additional -drawing-defined open cavity or gap. The coordinate-frame direction comparison -uses the recorded numerical threshold 1e-10; point tolerance is not a dimensional -error bound. - -`section_validation` separately reports source validity/closure/solid count, -section closure, valid material faces and inner/outer rings, and each point's -classification against the original solid, material faces, and displayed -polygons. Formal rendering checks its own actual sampling density. Open or -incomplete sections may render as `diagnostic_only`, but cannot become formal -evidence. Missing section checks are not auto-filled from measured geometry. - -On failure, inspect the section definition, intersection and display first. -`section_definition`, `section_intersection` and `section_display` failures -do not justify changing the model. Only independent solid evidence, confirmed -against the drawing, can establish a model-geometry error. Mass, center of mass, -inertia and high-precision volume matching remain outside drawing-reconstruction -acceptance unless the user separately requests them. +Model-section evidence (`measure_model_section_rdimensions`, `render_model_section_rpath`, the `section_checks` +contract), the `measurement_contract` of each ledger row and the executable evidence checks are in +`domains/drawing-inspection-evidence.md`. ## Section-plane parsing rules (deterministic, never asked) @@ -287,51 +214,6 @@ interpretation, model measurement and output-expression verdicts. Coverage counts `value_verified`, `association_verified`, and `layout_reviewed` separately, with missing DIM IDs for each column. -### Executable evidence checks - -Read the exact API pages for `inspect_drawing_coordinates_rreport`, -`assess_dimension_rverdict`, `build_section_annotation_rrecord`, -`validate_section_annotations_rreport`, and -`summarize_dimension_coverage_rreport`. The latter requires the complete expected -DIM list; omitted rows count as missing in every coverage column. - -Result version **2.1** requires an independent `measurement_contract` in each -ledger row. For example, a section's width is -`{"kind":"extent_width","definition":"axis_aligned_extent","direction":[1,0], -"point":null,"units":"mm","coordinate_space":"section_local"}`. -Height uses `extent_height` and `[0,1]`. Diameter uses `circle_diameter`, -minimum gap uses `minimum_boundary_gap`, both with null direction/point. -Directional material thickness uses `material_length_on_line`, a normalized -local direction and the specified local point. Geometry IDs bind the selected -contour; this separate contract binds what is measured on it. Missing contracts -cannot be inferred from whichever measurement happens to fall inside tolerance. - -The repository script `tools/verify_drawing_evidence.py --model part.step ---evidence evidence.json --out verification.json` independently remeasures and -reports all three gates. Its input contains `plane`, the complete `ledger`, -bound `annotations`, and `output_reviews` keyed by DIM ID. An output review -contains `layout_status: reviewed`, `association_status: verified` and independent -`evidence` references, plus the `dim_id`, `measurement_id`, `model_hash` and -`section_id` captured **when the image was reviewed**. Its `render_artifact` -contains absolute `image_path`, `sidecar_path`, and the reviewed -`image_sha256`, `sidecar_sha256`. The renderer records the image hash and each -annotation's content-derived ID in the sidecar. Validate both file hashes, -annotation content (including anchors and precision) and measurement identity. -Never replace old review IDs or hashes with current values during verification. -Old or missing identities require a new review of the actual current artifact. -Missing visual review remains pending even when numeric checks pass. - -Formal annotations also check the actual formatted value: the display step -must be no wider than the tolerance band, rounding error no larger than half -its width, and the displayed number itself inside the band. Zero-width bands -require exact representation. For 10.4 mm in [10.39,10.41], zero decimals are -rejected; one decimal also lacks the required resolution. Geometric conformance -continues to use the unrounded value and its uncertainty interval. - -An external total-error assessment must be tied to the exact `measurement_id` -and include `bound_mm`, `basis`, and `evidence`. These fields record a separate -metrology assessment; the SDK cannot establish its truth just by reading them. -Do not turn a fitted-circle residual or kernel edge tolerance into this bound. ## Discipline notes diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 841de786..6a59c31e 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -1,11 +1,18 @@ # Editor collaboration: working with a person in `sca edit` +**Read when:** a person has the code-first editor open (`sca edit`) and you edit the same notebooks (a selection, a cell reference or a tuned value arrives). + Use this domain when a person has the code-first editor open (`sca edit`) and you work on the same notebooks: they click, drag and tune; you read files and edit code. It is not a workflow. The modeling you do still follows the workflow you selected; this page says how to see what the person sees and how to hand work back. +**Unattended:** there is no person and no editor. Do not start `sca edit`; edit the notebook file directly, check it with +`sca run ` and read its report instead of the snapshot, the viewport image or a selection cell. A selection +you were handed in the task is resolved and checked like any selector. Never wait for a person to run, pick or tune +anything (`discipline/operating-modes.md`). + The notebook file is the shared state. The editor autosaves every edit to it, and every message the person gives you (a selection, a cell reference, a tuned value) is also something you can read from disk. Never rely on what the @@ -90,7 +97,7 @@ sca new bracket --kind part # a new notebook; the open editor lists it, `sca snapshot` only has an answer while the editor runs; it reports the last state the editor wrote. If the snapshot is old (`updated`), ask the person to -move the viewport or re-select, or read the notebook file instead. +move the viewport or re-select, or read the notebook file instead. (Unattended: read the notebook file.) ## The selection cell @@ -126,7 +133,8 @@ selected_item = [selected_edge_1, selected_edge_2] - A selector says where, not what: confirm before changing geometry. Resolve it and print small QL facts (count, a measurement) per the global rules. If it matches nothing the person's model changed since the pick: ask them to pick - again rather than guessing. + again rather than guessing. (Unattended: no selection will arrive; derive the selector from the task's evidence, print + its card, and record it in the report.) - Do not rename `selected_*` variables or delete the cell; the editor finds it again by its first line. When the person is done with the selection, they clear it themselves. @@ -140,7 +148,7 @@ selected_item = [selected_edge_1, selected_edge_2] name. Keep one feature per cell; give it a feature header. 4. Save the file. The editor shows the change (cells you edited show `stale` until run). Ask the person to run it, or run `sca run ` yourself - to check the model builds, and read the errors. + to check the model builds, and read the errors. (Unattended: always `sca run`.) ## Cell references diff --git a/docs/skill/references/domains/engineering-drawing.md b/docs/skill/references/domains/engineering-drawing.md index 7de98da7..9e1e1e69 100644 --- a/docs/skill/references/domains/engineering-drawing.md +++ b/docs/skill/references/domains/engineering-drawing.md @@ -1,5 +1,7 @@ # Task Domain: Engineering Drawing Generation +**Read when:** a validated part or product needs a GB-standard drawing sheet (DXF + PNG). + Generate GB-standard 2D engineering drawing sheets (DXF, plus a PNG rendered from that DXF for review) from finished 3D geometry with the declarative `simplecadapi.dxf_engine` namespace. Standards followed: GB/T 4458.4-2003 diff --git a/docs/skill/references/domains/export-and-translation.md b/docs/skill/references/domains/export-and-translation.md index 38d1639a..1e72bbea 100644 --- a/docs/skill/references/domains/export-and-translation.md +++ b/docs/skill/references/domains/export-and-translation.md @@ -1,5 +1,7 @@ # Task Domain: Export and Translation +**Read when:** a validated `.scadpkg` must be delivered as STEP, STL, OBJ, MJCF or an editable external CAD document. + Export validated product packages to neutral and mesh formats, and translate them into editable external CAD backends. diff --git a/docs/skill/references/domains/part-modeling.md b/docs/skill/references/domains/part-modeling.md index 691359bd..5e01187a 100644 --- a/docs/skill/references/domains/part-modeling.md +++ b/docs/skill/references/domains/part-modeling.md @@ -3,6 +3,9 @@ Model one physical, single-solid mechanical part with the core geometry API: primitives, profiles, 3D features, booleans, detail features, and transforms. +**Read when:** the deliverable is one physical single-solid part, or an existing part source needs a geometry edit (Role 4 +of `workflows/single-part-modeling.md`). + ## Use when - The deliverable is one physical part that will be manufactured or exported as diff --git a/docs/skill/references/domains/requirement-refinement.md b/docs/skill/references/domains/requirement-refinement.md index 1f271270..dd1b5614 100644 --- a/docs/skill/references/domains/requirement-refinement.md +++ b/docs/skill/references/domains/requirement-refinement.md @@ -3,6 +3,9 @@ Convert a user request — prose, reference images, technical drawings, or a combination — into an actionable modeling brief before any geometry is written. +**Read when:** every task starts here: any modeling, assembly, reconstruction, or export request is refined into a brief +first, and whenever inputs conflict, are ambiguous, or mix modalities. + ## Use when - Every task starts here: any modeling, assembly, reconstruction, or export @@ -38,6 +41,7 @@ combination — into an actionable modeling brief before any geometry is written ```text CAD brief: +- Mode: (first line; see `discipline/operating-modes.md`) - Model: , - Inputs: - Units: @@ -49,6 +53,7 @@ CAD brief: - Paths: - Validation targets: - Assumptions: +- Assumption ledger (unattended): | item | value | evidence | how to change it | the check that would show it wrong | ``` ## Input precedence @@ -111,6 +116,13 @@ The brief is complete only when its validation targets name the checks that will Own the methods, ask about the requirements: how a claim will be verified (an insertion-envelope sweep, a highlight render, a comparison view) is yours to propose in the plan — do not wait for the user to invent the test. What counts as success (fit, load direction, finish) is the user's call and belongs in the Ask-or-Record set. +**Unattended:** the clarification policy and the "confirm with the user" rule above change who decides, not what is +checked (`discipline/operating-modes.md`). Nothing is asked: every blocking item is recorded in the brief's assumption +ledger with its evidence and the check that would show it wrong. The interpreted shape of a reference image is still +described region by region before any geometry, checked against the measured evidence, and its confidence recorded; it is +not skipped. Conflicting dimensioned sources: record the conflict and the choice (measured over prose over inference) +instead of flagging it to a person. + ## Success criteria The brief is ready when it defines source path(s), units, coordinate system, diff --git a/docs/skill/references/domains/sketch-and-features.md b/docs/skill/references/domains/sketch-and-features.md index 3cfab83d..69c6e0cd 100644 --- a/docs/skill/references/domains/sketch-and-features.md +++ b/docs/skill/references/domains/sketch-and-features.md @@ -4,6 +4,9 @@ Author declarative, constraint-driven 2D profiles and promote them into topology, when the design intent is an editable sketch rather than direct geometry transcription. +**Read when:** the part's controlling dimensions are geometric constraints, or an editable, constraint-driven profile is +requested (sketch tier profiles and paths). + ## Use when - The part's controlling dimensions are naturally expressed as geometric diff --git a/docs/skill/references/domains/standard-parts.md b/docs/skill/references/domains/standard-parts.md index 9d2d142c..4a6bbd7e 100644 --- a/docs/skill/references/domains/standard-parts.md +++ b/docs/skill/references/domains/standard-parts.md @@ -1,5 +1,7 @@ # Task Domain: Standard Parts +**Read when:** the request names gears, ring gears, racks, cycloidal discs or ball bearings, or a mechanism built from them (details under "Use when"). + Use the parameterized standard-library factories before hand-modeling standard mechanical components. diff --git a/docs/skill/references/domains/step-inspection.md b/docs/skill/references/domains/step-inspection.md index cbb8dc3b..0b1a279e 100644 --- a/docs/skill/references/domains/step-inspection.md +++ b/docs/skill/references/domains/step-inspection.md @@ -1,5 +1,7 @@ # Task Domain: STEP/BREP Inspection +**Read when:** the input is an existing `.step`/`.stp` file and you need facts out of it, or a reconstruction candidate must be compared against a target STEP (details under "Use when"). + Extract bounded geometric facts from existing STEP files, compare targets and candidates, and gather reconstruction evidence with the `simplecadapi.inspect.brep` namespace. diff --git a/docs/skill/references/ql-playbook.md b/docs/skill/references/ql-playbook.md index 3c0c71c5..9b8d1015 100644 --- a/docs/skill/references/ql-playbook.md +++ b/docs/skill/references/ql-playbook.md @@ -186,76 +186,9 @@ Verified extensions: identity is NOT stable (section correspondence is kernel-matched), so select sides as a group or post-hoc — never index them. -## Pattern B — boolean seam curves (D-boss recipe) +## Recipes (worked examples) -A D-shaped boss standing on the long side face of a bar base, unioned with a -1 mm dip into the base and a top that rises 4 mm above the bar: the seam is a -closed 3-D curve set running across the base side face, the base top face and -the bottom imprint. All of it is selectable, then filletable, in two moves. - -```python -with scad.GraphSession(): - base = scad.make_box_rsolid(120.0, 30.0, 20.0, (0, 0, 0), - top_face_tag="base.top") - - # D boss = box U cylinder; cylinder bottom-face center sits at the - # midpoint of the boss box's +y bottom edge -> classic D outline - boss_box = scad.make_box_rsolid(24.0, 10.0, 24.0, (0, 19.0, 0.0)) - boss_cyl = scad.make_cylinder_rsolid(12.0, 24.0, (0, 24.0, 0.0), (0, 0, 1)) - boss = scad.union_rsolid(boss_box, boss_cyl) - - result = scad.union_rsolid(base, boss, clean=False) - - # parents by kernel-proven boolean origin role (arg 0 = body, arg 1 = tool) - body_sel = ql.faces().where(ql.origin_role("body")) - tool_sel = ql.faces().where(ql.origin_role("tool")) - - # the seam: edges with one distinct incident face per parent - seam = ql.edges().incident_to(body_sel, tool_sel, distinct=True).resolve(result) - - # name the seam, then fillet it — the selector feeds fillet directly - result = scad.apply_tag_rselection(result, seam, "seam.d") - seam_sel = ql.edges().incident_to(body_sel, tool_sel, distinct=True) - filleted = scad.fillet_rsolid(result, seam_sel, 2.0) - - # the patches inherit the seam-edge name (verified 7/7 patches) - ql.faces().where(ql.tag("seam.d")).resolve(filleted) -``` - -Verified seam inventory for the dimensions above (`clean=False`): 13 edges — -2 vertical lines on the side face (±12, y=15), 5 edges on the base top plane -(the D outline where the boss crosses z=20: 1.0 + 5.367 + 14.056 + 5.367 + -1.0), 6 on the bottom plane (same outline at z=0 plus the 24-long dip -imprint). With `clean=True` the coplanar bottom pieces unify and part of the -imprint seam disappears — keep `clean=False` when the seam itself is the -selection target. - -Facts that matter when filleting a seam: - -- **`fillet_rsolid` accepts a `ShapeSelector`** for its `edges` argument; no - need to materialize the list twice. -- A boss/base seam is mostly **concave**: the fillet ADDS volume - (82780.7 → 82841.1 at r=2). Never assert `v1 < v0` on seam fillets. -- The whole seam filleted at once is a hard kernel case; if a radius fails, - step down (2.0 → 1.5 → 1.0 verified ladder pattern). - -## Recipe: root blend via shared boundary - -Edges shared between two named bodies are the first-choice selection -(`discipline/geometric-validation.md`, edge-selection defaults): - -```python -walls = ql.faces().where(ql.prop("geom.type", "==", "CYLINDER")) -plate_top = ql.faces().where(ql.and_( - ql.prop("geom.normal.z", ">=", 0.999), - ql.prop("geom.center.z", ">=", 14.5), ql.prop("geom.center.z", "<=", 15.5))) -root = walls.shared_boundary(plate_top, to_kind="edge") - -with GraphSession(graph_id="blend") as s: - blended = scad.fillet_rsolid(solid, root.exactly(card_count), radius=2.0, - generated_faces_tag="fillet.root_patch") - s.capture_result(value=blended) -``` +Pattern B (boolean seam curves, the D-boss recipe, with seam inventory and fillet facts), the root blend recipe, tagged bore -> rim chamfer, and selected face -> sketch datum plane are in `ql-recipes.md`. The rules above and the failure signatures below apply to all of them. ### Shared-boundary failure signatures (read them, do not fight them) @@ -277,72 +210,6 @@ blending a seated feature, assert clearance: axis-to-neighboring-wall distance must exceed the blend radius (same family as fastener envelopes, `discipline/mechanical-modeling.md`). -## Recipe: tagged bore -> rim chamfer - -Tag the bore wall right after the cut (Ground rule 3: tag in time), -then chamfer its top rim: - -```python -bore_faces = bore.resolve(solid) # resolve first — see Ground rule 2 -tagged = scad.apply_tag_rselection(scope=solid, targets=bore_faces, - tag="role.fastener_bore") -bore_sel = ql.faces().where(ql.tag("role.fastener_bore")).exactly(1) -top_rim = ql.edges().incident_to(bore_sel, distinct=True).where( - ql.prop("geom.center.z", ">=", 14.0)) -rim_card = top_rim.resolve(tagged) -chamfed = scad.chamfer_rsolid(tagged, top_rim.exactly(len(rim_card)), - distance=1.0) -``` - -Gotchas this recipe absorbs: - -- `incident_to` on a cylindrical wall returns seam edges too (the - vertical seam of the cylinder surface sits mid-wall); filter rims by - an axial coordinate window. The rim card is one closed arc of length - `2*pi*r`. -- Passing an unresolved selector as `targets` (instead of the resolved - list) produces a clone foreign to the active session; the next - operation rejects it with "graph node not owned by active graph". - Tagging a shape that a later operation has already consumed fails - the same way — tag immediately after the op that produced the - faces (Ground rule 3). -- Objects live in one `GraphSession`; passing a solid into operations - under another session fails loudly. Keep the whole build in one. - -## Recipe: selected face -> sketch datum plane - -`make_sketch_rsketch(plane=...)` accepts `"XY"/"XZ"/"YZ"` or a plane -mapping — never a Face. Derive the mapping from the selected face: - -```python -def plane_mapping_from_face(face): - c, n = face.get_center(), face.get_normal_at() - z = (n.x, n.y, n.z) - ref = (1.0, 0.0, 0.0) if abs(z[2]) >= 0.9 else (0.0, 0.0, 1.0) - dot = sum(ref[i] * z[i] for i in range(3)) - x = [ref[i] - dot * z[i] for i in range(3)] - m = sum(v * v for v in x) ** 0.5 - x = [v / m for v in x] - y = (z[1]*x[2]-z[2]*x[1], z[2]*x[0]-z[0]*x[2], z[0]*x[1]-z[1]*x[0]) - return {"origin": (c.x, c.y, c.z), "x_axis": tuple(x), "y_axis": y} - -sketch = scad.make_sketch_rsketch(name="on_face", - plane=plane_mapping_from_face(selected_face)) -``` - -Pick the datum face itself with QL when the intent is structural -("largest up-facing face") rather than positional: - -```python -datum = (ql.faces().where(ql.prop("geom.normal.z", ">=", 0.999)) - .order_by(ql.value("geom.area"), desc=True).take(1).exactly(1)) -``` - -`extrude_rsolid(profile=..., ...)` accepts the selected Face directly -as a profile — same object, no re-derivation needed. Extruding the -top face of a 30×20 plate (bore opening r=6 after chamfer) by 2 mm -gives exactly `(30*20 - pi*6**2) * 2` volume. - ## Lineage routes (proving what an operation produced) | Question | Route | Verified semantics | diff --git a/docs/skill/references/ql-recipes.md b/docs/skill/references/ql-recipes.md new file mode 100644 index 00000000..1b595d4a --- /dev/null +++ b/docs/skill/references/ql-recipes.md @@ -0,0 +1,142 @@ +# QL Recipes + +**Read when:** you need a worked example of a QL selection: boolean seam curves, root blend, bore rim chamfer, or a sketch datum plane from a face. The model of how selection works, the rules, and what to do when a selection fails are in `ql-playbook.md`; read that first. + +Moved from `ql-playbook.md` unchanged. "Ground rule N" means the numbered rules in the playbook; the playbook's "Verified boundaries" lists the engine limits these recipes route around. + +## Pattern B — boolean seam curves (D-boss recipe) + +A D-shaped boss standing on the long side face of a bar base, unioned with a +1 mm dip into the base and a top that rises 4 mm above the bar: the seam is a +closed 3-D curve set running across the base side face, the base top face and +the bottom imprint. All of it is selectable, then filletable, in two moves. + +```python +with scad.GraphSession(): + base = scad.make_box_rsolid(120.0, 30.0, 20.0, (0, 0, 0), + top_face_tag="base.top") + + # D boss = box U cylinder; cylinder bottom-face center sits at the + # midpoint of the boss box's +y bottom edge -> classic D outline + boss_box = scad.make_box_rsolid(24.0, 10.0, 24.0, (0, 19.0, 0.0)) + boss_cyl = scad.make_cylinder_rsolid(12.0, 24.0, (0, 24.0, 0.0), (0, 0, 1)) + boss = scad.union_rsolid(boss_box, boss_cyl) + + result = scad.union_rsolid(base, boss, clean=False) + + # parents by kernel-proven boolean origin role (arg 0 = body, arg 1 = tool) + body_sel = ql.faces().where(ql.origin_role("body")) + tool_sel = ql.faces().where(ql.origin_role("tool")) + + # the seam: edges with one distinct incident face per parent + seam = ql.edges().incident_to(body_sel, tool_sel, distinct=True).resolve(result) + + # name the seam, then fillet it — the selector feeds fillet directly + result = scad.apply_tag_rselection(result, seam, "seam.d") + seam_sel = ql.edges().incident_to(body_sel, tool_sel, distinct=True) + filleted = scad.fillet_rsolid(result, seam_sel, 2.0) + + # the patches inherit the seam-edge name (verified 7/7 patches) + ql.faces().where(ql.tag("seam.d")).resolve(filleted) +``` + +Verified seam inventory for the dimensions above (`clean=False`): 13 edges — +2 vertical lines on the side face (±12, y=15), 5 edges on the base top plane +(the D outline where the boss crosses z=20: 1.0 + 5.367 + 14.056 + 5.367 + +1.0), 6 on the bottom plane (same outline at z=0 plus the 24-long dip +imprint). With `clean=True` the coplanar bottom pieces unify and part of the +imprint seam disappears — keep `clean=False` when the seam itself is the +selection target. + +Facts that matter when filleting a seam: + +- **`fillet_rsolid` accepts a `ShapeSelector`** for its `edges` argument; no + need to materialize the list twice. +- A boss/base seam is mostly **concave**: the fillet ADDS volume + (82780.7 → 82841.1 at r=2). Never assert `v1 < v0` on seam fillets. +- The whole seam filleted at once is a hard kernel case; if a radius fails, + step down (2.0 → 1.5 → 1.0 verified ladder pattern). + +## Recipe: root blend via shared boundary + +Edges shared between two named bodies are the first-choice selection +(`discipline/geometric-validation.md`, edge-selection defaults): + +```python +walls = ql.faces().where(ql.prop("geom.type", "==", "CYLINDER")) +plate_top = ql.faces().where(ql.and_( + ql.prop("geom.normal.z", ">=", 0.999), + ql.prop("geom.center.z", ">=", 14.5), ql.prop("geom.center.z", "<=", 15.5))) +root = walls.shared_boundary(plate_top, to_kind="edge") + +with GraphSession(graph_id="blend") as s: + blended = scad.fillet_rsolid(solid, root.exactly(card_count), radius=2.0, + generated_faces_tag="fillet.root_patch") + s.capture_result(value=blended) +``` + +## Recipe: tagged bore -> rim chamfer + +Tag the bore wall right after the cut (Ground rule 3: tag in time), +then chamfer its top rim: + +```python +bore_faces = bore.resolve(solid) # resolve first — see Ground rule 2 +tagged = scad.apply_tag_rselection(scope=solid, targets=bore_faces, + tag="role.fastener_bore") +bore_sel = ql.faces().where(ql.tag("role.fastener_bore")).exactly(1) +top_rim = ql.edges().incident_to(bore_sel, distinct=True).where( + ql.prop("geom.center.z", ">=", 14.0)) +rim_card = top_rim.resolve(tagged) +chamfed = scad.chamfer_rsolid(tagged, top_rim.exactly(len(rim_card)), + distance=1.0) +``` + +Gotchas this recipe absorbs: + +- `incident_to` on a cylindrical wall returns seam edges too (the + vertical seam of the cylinder surface sits mid-wall); filter rims by + an axial coordinate window. The rim card is one closed arc of length + `2*pi*r`. +- Passing an unresolved selector as `targets` (instead of the resolved + list) produces a clone foreign to the active session; the next + operation rejects it with "graph node not owned by active graph". + Tagging a shape that a later operation has already consumed fails + the same way — tag immediately after the op that produced the + faces (Ground rule 3). +- Objects live in one `GraphSession`; passing a solid into operations + under another session fails loudly. Keep the whole build in one. + +## Recipe: selected face -> sketch datum plane + +`make_sketch_rsketch(plane=...)` accepts `"XY"/"XZ"/"YZ"` or a plane +mapping — never a Face. Derive the mapping from the selected face: + +```python +def plane_mapping_from_face(face): + c, n = face.get_center(), face.get_normal_at() + z = (n.x, n.y, n.z) + ref = (1.0, 0.0, 0.0) if abs(z[2]) >= 0.9 else (0.0, 0.0, 1.0) + dot = sum(ref[i] * z[i] for i in range(3)) + x = [ref[i] - dot * z[i] for i in range(3)] + m = sum(v * v for v in x) ** 0.5 + x = [v / m for v in x] + y = (z[1]*x[2]-z[2]*x[1], z[2]*x[0]-z[0]*x[2], z[0]*x[1]-z[1]*x[0]) + return {"origin": (c.x, c.y, c.z), "x_axis": tuple(x), "y_axis": y} + +sketch = scad.make_sketch_rsketch(name="on_face", + plane=plane_mapping_from_face(selected_face)) +``` + +Pick the datum face itself with QL when the intent is structural +("largest up-facing face") rather than positional: + +```python +datum = (ql.faces().where(ql.prop("geom.normal.z", ">=", 0.999)) + .order_by(ql.value("geom.area"), desc=True).take(1).exactly(1)) +``` + +`extrude_rsolid(profile=..., ...)` accepts the selected Face directly +as a profile — same object, no re-derivation needed. Extruding the +top face of a 30×20 plate (bore opening r=6 after chamfer) by 2 mm +gives exactly `(30*20 - pi*6**2) * 2` volume. diff --git a/docs/skill/references/workflows/assembly-product-build.md b/docs/skill/references/workflows/assembly-product-build.md index 600b0d9f..873bb214 100644 --- a/docs/skill/references/workflows/assembly-product-build.md +++ b/docs/skill/references/workflows/assembly-product-build.md @@ -12,6 +12,47 @@ rack drives, cycloidal drives): standard-part usage lives here. Not for monolithic parts (`single-part-modeling.md`) or export-only requests (`export-and-translation.md` runs from an existing package). +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/assembly-product-build.md` | this page: steps, gates, failure routes | +| `domains/requirement-refinement.md` | step 1: product structure, parts manufactured vs purchased vs standard, motion, delivery | +| `domains/assembly-and-product.md` | placement, connectors, constraints, `scad.use` composition, capture | +| `discipline/assembly-positioning.md` | frames, connectors and constraints: how mating and motion intent is written and checked | + +## Read when + +| When | File | +| --- | --- | +| step 2: decomposing the product into part and assembly notebooks | `discipline/mechanical-modeling.md` | +| the mechanism uses gears, ring gears, racks, cycloidal discs or bearings | `domains/standard-parts.md` | +| authoring or editing any part or assembly notebook (before the first write) | `docs/guides/notebook-runtime.md` | +| composing notebooks, nested subassemblies, library parts, the cell cache | `docs/guides/notebook-composition-and-cache.md` | +| authoring a custom part (step 4) | `workflows/single-part-modeling.md` | +| writing a part source | `discipline/feature-tree-convention.md` | +| splitting a part or assembly source into cells | `discipline/notebook-cell-design.md` | +| the constraint solve fails or placement is wrong | `discipline/failure-and-repair.md` | +| a validation gate needs a check script | `discipline/geometric-validation.md` | +| defaults for clearances, backlash or walls are needed | `discipline/manufacturing-boundaries.md` | +| capturing the package and exporting (steps 9-10) | `docs/guides/product-export.md`, `domains/export-and-translation.md` | + +## Unattended mode + +Nothing in this workflow waits for a person except the places below; each binds an artifact in both modes. + +| Interactive gate | Unattended replacement | +| --- | --- | +| Step 1: ask about product structure, manufactured vs purchased vs standard, motion, materials, delivery targets | Record each as an assumption in the ledger (`value / evidence / how to change it / the check that would show it wrong`); the structure that the evidence supports wins | +| A part or mate that the request implies but no source supplies (purchased part without a STEP, unstated mate intent or degrees of freedom) | Record the blocker in the report; build only what the evidence supports; a stand-in is labelled as an assumption and never reported as the real part. Never invent the missing input | +| Step 2: a choice between decompositions or subassembly boundaries | Take the one that meets the stated requirements and is easier to change; record the other in the plan | +| Step 4: each custom part's own consent and verifier gates (`workflows/single-part-modeling.md`) | Run the unattended rows of that workflow; every part still passes its single-part gates before assembly | +| Step 6: unknown joint type or ground choice | Record the choice and its evidence in the ledger; ground only what the evidence says is fixed | +| Step 8: a person looks at placement renders | Look yourself: name the views, what you looked for, quote the observation; the QL placement checks still run | +| A second reviewer of the assembly | A fresh-context checklist pass quoting observations (subagent if offered); otherwise the report says the review was not independent | +| Constraint failure or placement error twice for the same cause | Change the constraint graph or the datum, or redefine the check in the report; do not retry (`discipline/failure-and-repair.md`) | +| Closing message to the person | `report.md`: assumptions, what was built and omitted, checks run and not run, reproduce commands (`discipline/operating-modes.md`) | + ## Task decomposition ```yaml @@ -48,10 +89,12 @@ repair_routes: 1. **Refine the requirement** (`domains/requirement-refinement.md`): product structure, which parts are manufactured vs purchased vs standard, motion requirements, materials, delivery targets. + **Unattended:** record each as an assumption in the ledger. 2. **Decompose the product** (`discipline/mechanical-modeling.md`): one part notebook per physical part, one assembly notebook; group functional units as nested subassembly notebooks that are placed, reasoned about, or repeated as units (`references/docs/guides/notebook-runtime.md`). + **Unattended:** pick the decomposition that meets the requirements and is easier to change; record the other. 3. **Select standard components first** (`domains/standard-parts.md`) when the mechanism uses gears, ring gears, racks, cycloidal discs, or bearings: never hand-model a standard shape. Compute the kinematics @@ -65,6 +108,7 @@ repair_routes: connectors on leaf parts (`add_connector_rpart`, `make_placement_connector_rconnector`, ...) at mating datums (`discipline/assembly-positioning.md`). + **Unattended:** use the unattended rows of `workflows/single-part-modeling.md`; each part still passes its gates. 5. **Compose the assembly** in the assembly notebook: bring each part or subassembly notebook in with `scad.use("part.py", **overrides)` (or call a `@scad.part` / `@scad.assemble` library builder inside a cell); @@ -75,6 +119,7 @@ repair_routes: (`discipline/assembly-positioning.md`): fixed for the root, revolute / prismatic for motion, gear / rack-pinion / belt for drivetrain relations, ground the components that are truly fixed (at least one). + **Unattended:** record the joint and ground choices with their evidence. 7. **Solve and read the evidence** (`solve_assembly_constraints_rassembly`, `inspect_assembly_constraints_rconstraintreport`, `measure_constraint_residual_rconstraintresidual`): constraint status and @@ -83,6 +128,7 @@ repair_routes: 8. **Validate positioning**: component placements realized as intended; mating faces flush or offset as specified; axes aligned. Use QL queries on the solved assembly, not eyeballing. + **Unattended:** look at the renders yourself and quote what you saw. 9. **Capture** (`domains/assembly-and-product.md`): `sca run .py --out out/.scadpkg`, or `capture(run.definition, ...)` from `run_notebook` — the single delivery @@ -120,11 +166,13 @@ composing notebooks. - Duplicate component ids / cycles / invalid placements → fix the structure. - Placement systematically wrong → datum or connector frame error; see `discipline/assembly-positioning.md`. + **Unattended:** the same cause twice means change the design or redefine the check in the report. ## Deliverables Part and assembly notebook paths, package path, constraint report, residuals, positioning checks actually run, assumptions. +**Unattended:** these go in `report.md` (`discipline/operating-modes.md`). **Required reading before authoring or editing any part source in this workflow:** `discipline/feature-tree-convention.md` — the block-structured diff --git a/docs/skill/references/workflows/drawing-reconstruction.md b/docs/skill/references/workflows/drawing-reconstruction.md index 93787654..f4aeaf3d 100644 --- a/docs/skill/references/workflows/drawing-reconstruction.md +++ b/docs/skill/references/workflows/drawing-reconstruction.md @@ -35,6 +35,9 @@ Standing discipline, binding for every role: because it builds. - **⛔ BLOCKING means stop.** Never decide on the user's behalf at a blocking gate. + **Unattended:** the ⛔ gates become self-review gates (`discipline/operating-modes.md`). Every blocking item is + decided from evidence and recorded in the assumption ledger; the gate still binds its artifact (`report_A.md`, + `REQUIREMENTS.md`, a ledger row per blocking item); `report.md` lists every decision. - **The parse report is the drawing's truth.** Every modeling decision traces to a report entry with its tier mark (〔图面〕/〔归属〕/〔推算〕). The report contains no function/purpose inference; interpretation of what @@ -50,6 +53,10 @@ Standing discipline, binding for every role: artifacts and written criteria. When no isolated reviewer tool is available, request the user's independent review and retain the actual verdict. An unavailable reviewer leaves the visual gate pending. + **Unattended:** a fresh-context check of your own against the written criteria replaces the isolated reviewer or the + user's review (a subagent if the host offers one). Quote what you observed per view and DIM, record the reviewer as + `self (fresh context)` with the hashes of the reviewed files, and list the review as not independent in the report. + The visual gate closes only on that written verdict. - **Standard parts first; sketch tier is the default part form; every part source follows the Feature Tree Convention** (`discipline/feature-tree-convention.md`). @@ -90,452 +97,72 @@ Sn: (last stage) -> roundtrip: generate sheet -> roundtrip: parse report B A failed stage does not advance the TODO; it opens a `repair:` item for the owning artifact and repeats the same Role 3/4 loop. ---- - -## Role 1 — Drawing Requirement Confirmer ⛔ - -Convert the drawing into `drawing_work/report_A.md` and `REQUIREMENTS.md`. -No geometry, no probe builds, no source-file writes before this gate closes. - -1. Copy the source PDF into `/drawing_work/source/` first - (transfer directories expire). Run the parsing SOP steps 0–7 of - `domains/drawing-inspection.md` exactly — checkup and route verdict, text - coordinate preflight, inventory, overview pass, region reading, per-view - calibration and measurement, separate association verification, semantic assembly. -2. If the route verdict is not `vector`, stop the route: name what is - missing (raster-only sheet, mixed content) and ask the user for a vector - PDF or the native DXF/DWG. Never improvise an OCR path. -3. Write `report_A.md` in the ten-section skeleton. Every dimension gets a - stable `DIM-xxx` id and all ledger fields required by the inspection domain. - Unresolved association prevents a verified verdict for that DIM; independent - stages can continue. Every section view in the view list also - records its resolved cut-plane spec (plane position, normal, viewing - direction) per the Section-plane parsing rules (R1–R7) of - `domains/drawing-inspection.md` — deterministic, never asked; a view - whose plane cannot be resolved uniquely is marked `unreviewable` in the - gap list. Unrecoverable content (external - schedules, blank title fields, unlabeled constructions) goes to section 9 - verbatim — measured-and-marked where possible, listed as missing where - not. -4. Refine the requirement into `REQUIREMENTS.md` - (`domains/requirement-refinement.md`): units and coordinate convention - fixed from the sheet's views (front view, up axis, datum story from the - GD&T section), named parameters seeded from the dimension ledger, - verification intent, export targets. Technical-requirement lines are - classified: geometry-affecting (model) vs process/manufacturing notes - (deliverable remarks, never modeled). -5. Batch every blocking item into ONE ask round: disputed shape-level - interpretation, missing external schedules (e.g. curve coordinate - tables), approximation approvals for sheet-unsupported geometry, and - material assumptions where a check needs one. Record everything in the - Ask-or-Record ledger with the user's wording preserved. - - - -### Host binding: designing the Role 1 `ask` payload (omp) - -One batched `ask` call for all items that block `REQUIREMENTS.md`. One -decision per question; name the region, view, or DIM id and state the -evidence or gap; options mutually exclusive and operational (what gets -recorded, what modeling follows); conservative clearly-labeled assumption -first with `recommended: 0` — a recommendation, never consent; include a -concrete correction path. Stable machine-readable `id`s keyed to ledger rows -(e.g. `arc_schedule_missing`, `unlabeled_hatched_circles`). After the call, -copy answers into the ledger verbatim; timeout or dismissal keeps the gate -blocked and stops — never continue by silently selecting the recommended -option. - - - - - -### Host binding: designing the Role 1 `question` payload (opencode) - -One batched `question` call. No `id` field: key each ledger row to the -`header` (≤30 chars) derived from the stable identifier (e.g. `arc -schedule`, `hatched circles`); no `recommended` index: put the conservative -option first with a `(Recommended)` label suffix. Free-text custom answers -arrive automatically — phrase the question so the needed correction is -obvious when no option fits. Copy selected labels and custom answers into -the ledger keyed by header; a dismissed question is an unanswered blocking -row: keep the gate blocked and stop. - - - - - -### Host binding: requirement questions using available host tools - -Use the host's available question tool for the batched requirement questions; -if none is available, ask in a concise message. Key each answer to its DIM/view -or requirement entry and record the user's words. Do not invent tool names or -interpret a timeout as consent. Existing explicit authorization still applies; -ask only for requirements that remain unresolved. - - - -Gate: the ask round returned answers, consent is recorded in the ledger, -`report_A.md` and `REQUIREMENTS.md` exist. Entering Role 2 without this -gate is the cardinal violation. - -## Role 2 — Master Planner - -From `report_A.md` and `REQUIREMENTS.md` only — no API detail, no -step-level modeling. - -1. **Feature inference.** Build the feature hypothesis list: one entry per - inferred feature (base body, segments, bores, groove lattices, bosses, - lugs, modifiers), each citing its evidence (`DIM-xxx` ids, view-list - entries, 〔归属〕 conclusions) and carrying a confidence mark. Check - `scad.std.gear` / `scad.std.bearing` before hand-modeling anything - standard-like. Function/purpose narrative stays here and in the user - conversation — it never re-enters the parse report. -2. **Stage plan.** Design the construction as `S1..Sn`: base body first, - then major segments, feature groups as their own late stages (pattern - lattices after their prototype feature is proven), modifiers last. Pair - every stage with the DIM-id subset it must satisfy. -3. Write `BUILD_PLAN.md`: stage output boundaries, datum story, DIM-id - mapping, hypothesis list. Role 3 appends verification contracts; Role 4 - appends modeling hypotheses while the loop runs. -4. Initialize the TODO tool with phases `Requirements / Plan / Build & - Verify / Roundtrip / Export`; per stage create `plan verifier`, `model`, - `run verifier` items; create the roundtrip tasks - (`roundtrip: generate sheet`, `roundtrip: parse report B`, - `roundtrip: diff and triage`). Mark Requirements and Plan done; start - the first stage's `plan verifier`. - -Present the plan in ≤10 lines. No approval gate; enter the Role 3/4 loop. - - - -### Host binding: TODO tool (omp) - -Tool `todo`: `init(list=[{phase, items[]}])`, `start(task)`, `done(task)`, -`append(phase, items[])`, `view`. Task strings are stable identifiers — -quote them exactly. Keep the stage's `model` item pending until its -`plan verifier` artifact exists; a failed `run verifier` opens a -`repair:` item for the owner; never advance while an owner is open. Batch -`todo` calls with real work in the same turn. - - - - - -### Host binding: TODO tool (opencode) - -Tool `todowrite`: submit the whole list every call as -`todos=[{content, status, priority}]` with statuses -`pending / in_progress / completed / cancelled`. `content` strings are the -stable identifiers — quote them exactly (`S1 plan verifier`, -`repair: S1 verifier bore depth`, `roundtrip: diff and triage`). Exactly one -item `in_progress` at a time; mark `completed` only at the real gate; a -blocked item stays `in_progress` with a follow-up item recording the -blocker. Keep the stage's `model` pending until its `plan verifier` -artifact exists; a failed `run verifier` opens a `repair:` item for the -owner. - - - - - -### Host binding: plan and checklist state - -Use the host's available plan/TODO tool. If none exists, maintain the same stage -checklist in `BUILD_PLAN.md`, updating the artifact after each real result. -Keep `plan verifier`, `model`, `run verifier` and any `repair:` items distinct; -complete them only when the corresponding evidence exists. - - - -## Role 3 — Verifier Planner - -Runs before every stage's modeling work and owns the roundtrip contract. -This is "tests before implementation" applied to sheet evidence. - -For the current `S` stage, in order: - -1. **Stage output**: name the substructure, its datum relationship, and the - `DIM-xxx` subset it must satisfy; state what is deliberately out of - scope. -2. **Acceptance criteria** from the ledger subset: dimensions with their - tolerance bands, required topology (face/hole/axis counts), interfaces - with already-built geometry, BREP validity and single-solid expectations, - visual criteria where a view must confirm attachment. - Apply the scope restriction above when planning the verifier: no center-of-mass, - mass, inertia, or high-precision volume-equivalence checks without a separate - user requirement. Record the requirement/validity condition and threshold - basis for each blocking assertion; do not introduce gates during debugging. - For every DIM write a measurement contract before modeling: feature/geometry - selection, datum, direction, section origin/normal/basis, algorithm, sampling - configuration, accuracy class, error-bound evidence and tolerance basis. - Verify true circle geometry for diameters; use continuous extrema for envelopes. - Define wall thickness as minimum boundary gap, radial thickness, or material - length along a specified line before selecting the algorithm. A fitted circle - is only a candidate; sampled nesting is only a pair-selection candidate. - Neither fit residual, intersection `tolerance`, nor additional sampling alone - establishes the total error bound. Record convergence checks as such. - An approximate value with reliable bound e passes only when [value-e,value+e] - is wholly inside the tolerance band. Boundary overlap or unknown error is - `pending`; refine the algorithm or obtain a supported uncertainty assessment. - Cite any default tolerance actually specified by the sheet; otherwise record - missing tolerance basis and keep conformance pending, never invent a standard. - Unreliable fits or measurements stay pending. They cannot justify changing - CAD dimensions. For circle candidates record residuals, angular coverage, - conditioning, closure and actual edge types; a local arc, gapped contour or - nearly collinear sample set cannot establish a full circular feature. -3. **Deterministic checks**: iterate hypothesis snippets in the execution - environment; good and bad probes must show each check separates pass - from fail. QL-only topology enumeration (plural getters take an index or - are driven through `ql.().resolve`). Reject checks that merely - print a plausible number. -4. **Executable verification script** with explicit assertions and named - measurements; append `Verifier contract: / Criteria: <...> / - Script: ` to `BUILD_PLAN.md` with hypothesis evidence and known-bad - proof. Complete the stage's `plan verifier` TODO; only then may `model` - start. After Role 4 finishes, run the exact prepared script; only its - passing output closes the stage. -5. **Roundtrip contract** (written once, before S1): the coverage table - mapping every ledger `DIM-xxx` to its verification channel — a generated - sheet dimension where the drawing engine can express it, a - **section-evidence render** where the drawing's section views express it - (cut plane from the view list, measured dimensions from - `measure_model_section_rdimensions`, annotated render via - `render_model_section_rpath`, compared against the drawing's viewport by - you), or direct QL model measurement otherwise; - for each section view, a section-evidence task (plane spec, expected - DIM-id set, provenance viewport); the sheet declaration plan derived - from report A's view list; the pass criterion (every parameter within its - original tolerance band with sufficient accuracy; defaults only when the sheet - explicitly supplies a standard/class). - -Role 3 may prepare visual verification (named views, regions, verdict -fields for the isolated reviewer) but never certifies its own renders. - - - -### Host binding: persistent REPL and TODO state machine (omp) - -Use the persistent `eval` tool (language `py`) for hypothesis snippets, -known-bad probes, and the verification scripts; keep cells incremental. -Never treat a modeling success message as verification: invoke the prepared -verifier and record its output. Drive the stage gates through `todo` -exactly as declared in Role 2; close `plan verifier` only when contract, -executable check, hypothesis evidence, and known-bad proof exist; after -Role 4's run, start and complete `run verifier` on the script's real output. -On failure do not rewrite the criterion to clear the list — open -`repair: ` for the verifier or the model and repeat the loop. - - - - - -### Host binding: file-based hypothesis loop (opencode) - -No persistent REPL: every Python run is a fresh process. Keep hypothesis -snippets, known-bad probes, and final checks as numbered files under -`/verify/` (`s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`), -run them via `bash`, and persist intermediate state to files (JSON, -pickle, or captured packages) between runs. The script file is the -evidence. Gate the stages through `todowrite` with the exact task strings; -`todowrite`'s echoed list is the re-grounding source after any interruption. - - - - - -### Host binding: file-based verification - -No persistent REPL is assumed. Write hypotheses, known-bad probes and final -checks under `/verify/`, and execute them through the available shell -or execution tool. Retain scripts and JSON results so the stage can resume from -files. Update the plan/checklist from actual outputs; a failed check reopens its -owning artifact and cannot be cleared by weakening the criterion. - - - -## Role 4 — Detail Modeling Planner & Builder - -Starts only after the stage's verifier contract and executable check exist. - -1. Read the contract, then choose construction from the geometry: profile - and sketch first when they control the shape (constrained sketches, - promotion, `extrude/revolve/loft/sweep_rsolid`); documented primitives - when they map directly; named feature solids combined deliberately with - `union/cut/intersect_rsolid` (positive overlap, tool overshoot); patterns - only after one representative feature is proven; topology-sensitive - features (fillet/chamfer/shell) last. The ladder expresses intent — it - is not an API whitelist; read the exact API page before every first - call. -2. Name every construction step for its part role or feature - (`mounting_base`, `port_bore_tool`, `front_flange`), never its creation - order. Attach `role.*` / `feature.*` / `tool.*` tags; numeric facts in - metadata, not tags. -3. Probe the construction on minimal solids before editing the part source; - print types, counts, volumes, bounds as the contract requires; append - `Hypothesis / Result / Adopted` rows to `BUILD_PLAN.md`. -4. Edit the part source with keyword arguments, named parameters, FTC block - structure, and boundary comments; selection cards before every - fillet/chamfer. -5. Run the source and hand the stage geometry to Role 3. The stage passes - only when all assertions, measurements, and required visual verdicts - pass. On failure: no weakened checks — determine the owner (contract or - model), open a `repair:` item, repeat the loop. - - - -### Host binding: persistent REPL (omp) - -Hypothesis construction lives in `eval` (names survive across calls); the -real source run lives in bash. Close `S model` only after the source run -completes, then start `S run verifier` for Role 3. - - - - - -### Host binding: file-based hypothesis loop (opencode) - -Run incremental construction hypotheses as numbered scripts under -`/hypotheses/` via `bash`; carry geometry between probes by -writing files (pickle/JSON/`capture`) and reloading, or by re-materializing -from the part source. The source run itself also runs in `bash`. A modeling -success message is never the verification result. - - - - - -### Host binding: modeling execution and independent image review - -Run the part source with the available execution tool and preserve the stage's -verification outputs. For image judgments, use an available isolated reviewer -with the render paths and written criteria, or the user's explicit review when -no such tool exists. The context that generated the image cannot provide its -own acceptance verdict. Store reviewer identity, result and reviewed artifact -hashes; lack of a reviewer leaves the visual gate pending. - - - -## Drawing-roundtrip acceptance - -Owned by Role 3, executed after the last stage closes. This loop replaces -impression with evidence: the model must regenerate the sheet's content. - -1. **Generate sheet B.** From the validated model, drive the engineering- - drawing engine (`domains/engineering-drawing.md`) in batch: declare the - view set from report A's view list (subset the engine supports), with - dimensions covering the sheet-expressible ledger parameters. Read - `report()`; declaration-level problems are re-declared, never - action-patched. Export the DXF/PDF plus rendered PNGs. - Build annotations from verified measurement records. Section evidence uses - `build_section_annotation_rrecord`; native sheet dimensions require equivalent - bindings to their model-measurement records. Copying A's nominal values is not - a model measurement. - Bind `DIM ID → feature/contours → section definition → measurement ID → model hash`. - Independently bind `measurement_contract` from the ledger: dimension kind, - direction, definition, line point, units and coordinate space. A contour ID - shared by width and height cannot substitute for this contract. - Before rendering, `validate_section_annotations_rreport` independently reruns - geometry measurement and checks values, target IDs, section/configuration and - current model hash. A model or section change invalidates the old evidence - (conservative invalidation of all model evidence is acceptable). Preserve - unrounded measured values for acceptance; configure displayed decimals from - the tolerance band. Reject a display step wider than the band, rounding error - greater than half its width, or a displayed number outside it. Keep - `measured=999`, wrong-axis, wrong-definition, coarse-display and stale-model cases as - required negative tests. Raw rendering remains permitted but cannot pass this gate. -2. **Generate section evidence.** For every section view in report A's view - list, cut the model at the recorded plane (`measure_model_section_rdimensions`), - verify the ledger-matching dimension candidates and their accuracy, and render the annotated - section (`render_model_section_rpath`). The annotated values are model - measurements only when their source binding passes independent validation. - Declare section_strategy explicitly (auto/material/edges) and archive the - method actually used. Formal section evidence requires the section_checks - contract: independently resolved origin, normal and local axes, expected solid, - material-face and hole counts, representative material/void points, point - tolerance and its basis. Check model validity/closure, section closure, valid - material faces and topological outer/inner wires. Classify each point against - the original solid, material-face intersection and displayed polygons. Cover - every material face and every hole; boundary/uncertain points remain pending. - An image containing open or incomplete contours is diagnostic output only. - First verify the section definition, intersection and display. Modify the - model only when independent solid checks establish a geometry error supported - by the drawing; a rendering failure never establishes that by itself. - Record **layout readability** (legibility, clipping, overlap) separately from - **feature association** (anchor, target geometry, leader path, text box). - Automatic placeholder anchors are not association evidence. Tables require - feature numbers plus view/detail references that permit checking the mapping. - Archive local axes, final rotation/scale, canvas and pixel transforms, text - boxes and leader paths; test interior-point forward/inverse transforms. - Visual review records must retain the DIM/measurement/model/section IDs and - SHA-256 of the actual reviewed PNG and sidecar. The acceptance script compares - these with current files and annotation content; it must never overwrite a - review's old IDs. Changes to anchors, displayed precision, render layout or - either artifact require review of the new artifact even if model IDs agree. - Compare each render against the drawing's own - viewport crop (both are yours) and emit per-DIM verdicts - (`consistent` / `different` / `unreviewable` + reasons); hand both images - to the isolated reviewer when visual arbitration is required. -3. **Parse report B blind.** Re-run Role 1's SOP (steps 0–7) on sheet B and - write `drawing_work/report_B.md` in the same skeleton — without reading - report A's values. B carries its own `DIM-xxx` ids for its own sheet. - Blind reading tests communication. It never substitutes for model geometry - measurement; A/B numerical agreement must still have a model-derived source. -4. **Diff.** Join verdicts to A's ledger parameters (geometry identity, - not id text): sheet-expressible parameters join against report B's - entries, section-expressible parameters against the section-evidence - verdicts. Verdict per parameter: `consistent` (within the original - tolerance band; unspecified tolerances per the sheet's default - standard), `different`, or `not-expressible`. The diff table (parameter, - A value, B value, verdict, channel) is a deliverable. -5. **Not-expressible fallback.** Parameters neither channel can carry - (hatch-level geometry, features invisible in every resolved view) - are verified by direct QL measurement of the model against the ledger — - same tolerance criterion, different evidence channel, recorded in the - coverage table. -6. **Triage every `different`.** Re-open the original drawing evidence - (re-render the provenance viewport) before deciding: - - **Sheet-generation artifact** — `report()` warnings, engine - limitations, projection placement faults: fix the declaration, - regenerate B, re-diff. The model is never touched for this. - - **Section-plane artifact** — the cut plane was resolved wrong (R1–R4 - misapplied): correct the plane spec in report A's view list, re-render - the section evidence, re-diff. The model is never touched for this. - - **Parse error in report A** — the original evidence contradicts the - ledger entry: correct `report_A.md` (tier marks intact), re-derive the - affected hypotheses and stages, and re-model through the owning stages. - - **Model error** — the evidence supports the ledger and the model - measures otherwise: repair the owning source parameter (FTC block), - re-run the stage verifier, then regenerate B. -7. **Convergence.** Two full rounds without convergence → ⛔ stop and ask - the user with the diff table and the triage evidence. Blind parsing, - section evidence, and delegated visual review apply to every round — no - shortcuts on retries. - -Pass: each DIM separately passes (1) original interpretation — value, tolerance, -association and coordinate evidence; (2) model measurement — actual geometry and -adequate accuracy; (3) output expression — the matching measurement supplied the -number and the feature association is correct. The isolated reviewer also passes -layout readability and feature association separately for sheet B and section -pairs. `assess_dimension_rverdict` records the three gates. Coverage must enumerate -value verification, association verification and layout review independently; -missing evidence cannot be hidden in a combined pass count. A fallback measurement -can satisfy geometry, but cannot certify an absent or incorrect output expression. - -## Role 5 — Exporter - -Read the export rows from `REQUIREMENTS.md`. Durable canonical delivery is -`sca run .py --out out/.scadpkg` (or -`capture(run.definition, ...)` after `run_notebook`); external formats through -`domains/export-and-translation.md`. Post-export gate: re-open the package -in a fresh process and confirm definition/solid counts; list every produced -file with size. - -Final delivery ⛔: evidence pack — `report_A.md`, `report_B.md`, the diff -table, calibration records, provenance viewports, sheet B renders — plus -assumptions made, checks not run, and the process-note remarks from the -technical-requirements classification. Record the user's verdict; a -rejection routes back through the role map to the owning artifact. +## Role and stage pages + +| Page | Holds | +| --- | --- | +| `workflows/drawing-reconstruction/role-1-drawing-confirmer.md` | Role 1: parse report A, `REQUIREMENTS.md`, the ask round and its host bindings | +| `workflows/drawing-reconstruction/role-2-master-planner.md` | Role 2: hypothesis list, stage plan, `BUILD_PLAN.md`, TODO bindings | +| `workflows/drawing-reconstruction/role-3-verifier-planner.md` | Role 3: per-stage verification contracts, roundtrip contract, hypothesis-loop bindings | +| `workflows/drawing-reconstruction/role-4-detail-builder.md` | Role 4: construction ladder, probes, source edits, modeling bindings | +| `workflows/drawing-reconstruction/roundtrip-acceptance.md` | sheet B, section evidence, blind report B, diff, triage, pass criteria | +| `workflows/drawing-reconstruction/role-5-exporter.md` | Role 5: package, exports, final delivery | + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/drawing-reconstruction.md` | scope, standing discipline, role map (this page) | +| `workflows/drawing-reconstruction/role-1-drawing-confirmer.md` | the first stage: Role 1 | +| `domains/drawing-inspection.md` | the parsing SOP, tiers and report skeleton Role 1 runs verbatim | +| `domains/requirement-refinement.md` | Role 1 turns the parse into `REQUIREMENTS.md` | + +## Read when + +| When | File | +| --- | --- | +| Role 1 writes the ledger rows, or any section view needs evidence | `domains/drawing-inspection-evidence.md` | +| Role 1 writes `REQUIREMENTS.md` | `discipline/requirement-and-cad-brief.md` | +| Role 2 starts | `workflows/drawing-reconstruction/role-2-master-planner.md` | +| Role 2 picks the datum story and stage order | `discipline/mechanical-modeling.md` | +| Role 2 sets the datum story | `discipline/datums-and-coordinate-systems.md` | +| Role 3 starts (before every stage) | `workflows/drawing-reconstruction/role-3-verifier-planner.md` | +| Role 3 writes a stage's checks | `discipline/geometric-validation.md` | +| Role 4 starts | `workflows/drawing-reconstruction/role-4-detail-builder.md` | +| Role 4 builds a stage | `domains/part-modeling.md` | +| Role 4 orders features | `discipline/feature-ordering.md` | +| any part source is authored or edited | `discipline/feature-tree-convention.md` | +| any part source is run or loaded by a verifier | `docs/guides/notebook-runtime.md` | +| the last stage closes | `workflows/drawing-reconstruction/roundtrip-acceptance.md` | +| sheet B is generated | `domains/engineering-drawing.md` | +| the roundtrip has passed | `workflows/drawing-reconstruction/role-5-exporter.md` | +| Role 5 exports | `domains/export-and-translation.md` | +| Role 5 exports an assembly-rooted package | `domains/assembly-and-product.md` | +| a check fails twice for the same cause, or repeated dead ends | `discipline/failure-and-repair.md` | + +## Unattended mode + +A service that hands over a drawing wants a model and a report. Decide the mode first (`discipline/operating-modes.md`); +in unattended mode never ask, write the assumption ledger into `REQUIREMENTS.md` and `report.md` (the report of +`discipline/operating-modes.md`, with the diff table and the evidence pack), and never weaken a check. + +| Interactive gate | Unattended replacement | +| --- | --- | +| Standing discipline: ⛔ BLOCKING means stop | Self-review gate; the gate still binds its artifact; every decision is in the ledger and the report | +| Standing discipline: image judgments from an isolated reviewer, or the user's review | Fresh-context check against the written criteria, quoted observations, hashes of the reviewed files; the report says it was not independent | +| Role 1 step 2: route verdict not `vector`, ask for a vector PDF or DXF/DWG | Record the blocker in the report, stop the route, build nothing, never improvise OCR | +| Role 1 step 5: the one batched ask round (shape interpretation, missing schedules, approximation approvals, material) | Record each as a ledger row chosen from the evidence; skip the host ask/question bindings | +| Role 1: ambiguous view or ambiguous association | Take the reading with the verified association, mark it 〔归属〕, list the alternative in section 8; an unresolved association keeps that DIM unverified while independent stages continue | +| Role 1: unreadable dimension or text | The DIM stays `unresolved` with the crop quoted; modelled only from an accepted-calibration measurement marked 〔推算〕, else listed in section 9 and reported | +| Role 1: missing sections, external schedules, blank fields, partial sheet set | A section whose plane cannot be resolved is `unreviewable` in the gap list; its DIMs take the not-expressible fallback (direct QL measurement) and their output expression stays pending; missing schedules or sheets go to section 9 and the report; never invented | +| Role 1 consent gate (⛔ user consent received) | Self-review gate: re-read `report_A.md` and `REQUIREMENTS.md` against the sheet, one paragraph of decision with open risks, every required field filled | +| Role 2 plan presented (no approval gate) | No change: record the plan in `BUILD_PLAN.md` | +| Role 3 may prepare visual verification for the isolated reviewer | Fresh-context check by a context that did not render the images; listed as not independent | +| Role 4 host binding: the user's review of an image | The same fresh-context check; the visual gate stays pending until its written verdict exists | +| Roundtrip step 2 and the Pass paragraph: isolated reviewer arbitrates layout readability and feature association | The fresh-context check, per-DIM verdicts with quoted observations, readability and association kept separate | +| Roundtrip step 7: two rounds without convergence, ⛔ ask the user | Stop the loop, keep the diff table and triage evidence, mark the DIMs `different` or `pending`, no pass claim; the part source and reports are the deliverable | +| Failure route: roundtrip differences that survive triage, ⛔ user | The same as step 7 | +| Role 5 final delivery ⛔: record the user's verdict | The evidence pack, assumptions, checks not run and process notes go into `report.md`; no verdict is recorded as approval | --- @@ -564,6 +191,7 @@ rejection routes back through the role map to the owning artifact. - Groove-lattice or pattern construction diverges → re-probe the single representative feature; reconsider the stage split, not blind retries. - Roundtrip differences that survive triage → ⛔ user, with the diff table. + **Unattended:** the same as roundtrip step 7 (stop the loop, mark the DIMs, report; no ask). - Repeated dead ends → `discipline/failure-and-repair.md`; reconsider the stage plan. diff --git a/docs/skill/references/workflows/drawing-reconstruction/role-1-drawing-confirmer.md b/docs/skill/references/workflows/drawing-reconstruction/role-1-drawing-confirmer.md new file mode 100644 index 00000000..8bae63d9 --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/role-1-drawing-confirmer.md @@ -0,0 +1,107 @@ +# Drawing Reconstruction: Role 1, Drawing Requirement Confirmer + +Part of `workflows/drawing-reconstruction.md` (outline, standing discipline, role map). Load when Role 1 starts. + +## Role 1 — Drawing Requirement Confirmer ⛔ + +Convert the drawing into `drawing_work/report_A.md` and `REQUIREMENTS.md`. +No geometry, no probe builds, no source-file writes before this gate closes. + +1. Copy the source PDF into `/drawing_work/source/` first + (transfer directories expire). Run the parsing SOP steps 0–7 of + `domains/drawing-inspection.md` exactly — checkup and route verdict, text + coordinate preflight, inventory, overview pass, region reading, per-view + calibration and measurement, separate association verification, semantic assembly. +2. If the route verdict is not `vector`, stop the route: name what is + missing (raster-only sheet, mixed content) and ask the user for a vector + PDF or the native DXF/DWG. Never improvise an OCR path. + +**Unattended:** do not ask for a vector PDF. Record the blocker (the route verdict and what is missing) in the report, +stop this route, and build nothing from a non-vector sheet. `report.md` carries the route verdict and its evidence; +never improvise an OCR path and never invent the missing input. +3. Write `report_A.md` in the ten-section skeleton. Every dimension gets a + stable `DIM-xxx` id and all ledger fields required by the inspection domain. + Unresolved association prevents a verified verdict for that DIM; independent + stages can continue. Every section view in the view list also + records its resolved cut-plane spec (plane position, normal, viewing + direction) per the Section-plane parsing rules (R1–R7) of + `domains/drawing-inspection.md` — deterministic, never asked; a view + whose plane cannot be resolved uniquely is marked `unreviewable` in the + gap list. Unrecoverable content (external + schedules, blank title fields, unlabeled constructions) goes to section 9 + verbatim — measured-and-marked where possible, listed as missing where + not. +4. Refine the requirement into `REQUIREMENTS.md` + (`domains/requirement-refinement.md`): units and coordinate convention + fixed from the sheet's views (front view, up axis, datum story from the + GD&T section), named parameters seeded from the dimension ledger, + verification intent, export targets. Technical-requirement lines are + classified: geometry-affecting (model) vs process/manufacturing notes + (deliverable remarks, never modeled). +5. Batch every blocking item into ONE ask round: disputed shape-level + interpretation, missing external schedules (e.g. curve coordinate + tables), approximation approvals for sheet-unsupported geometry, and + material assumptions where a check needs one. Record everything in the + Ask-or-Record ledger with the user's wording preserved. + + +**Unattended:** there is no ask round (the host ask/question bindings below are skipped). Decide every blocking item +from the evidence (measured over prose over inference; the conservative option when the evidence ties) and write it in +the ledger as `value / evidence / how to change it / the check that would show it wrong`. Disputed shape-level +interpretation or an ambiguous view: take the reading whose association is verified, mark it 〔归属〕 and list the +alternative in report section 8. An unreadable dimension stays `unresolved` (quote the crop); it is modelled only from +an accepted-calibration vector measurement marked 〔推算〕, otherwise it goes to section 9 and its stage proceeds +without it. A missing external schedule is measured-and-marked or listed as missing, never invented. An approximation +for sheet-unsupported geometry is used only when needed, labelled 〔推算〕, and ledgered. A material assumption is +chosen, ledgered, and supports no strength claim. + + +### Host binding: designing the Role 1 `ask` payload (omp) + +One batched `ask` call for all items that block `REQUIREMENTS.md`. One +decision per question; name the region, view, or DIM id and state the +evidence or gap; options mutually exclusive and operational (what gets +recorded, what modeling follows); conservative clearly-labeled assumption +first with `recommended: 0` — a recommendation, never consent; include a +concrete correction path. Stable machine-readable `id`s keyed to ledger rows +(e.g. `arc_schedule_missing`, `unlabeled_hatched_circles`). After the call, +copy answers into the ledger verbatim; timeout or dismissal keeps the gate +blocked and stops — never continue by silently selecting the recommended +option. + + + + + +### Host binding: designing the Role 1 `question` payload (opencode) + +One batched `question` call. No `id` field: key each ledger row to the +`header` (≤30 chars) derived from the stable identifier (e.g. `arc +schedule`, `hatched circles`); no `recommended` index: put the conservative +option first with a `(Recommended)` label suffix. Free-text custom answers +arrive automatically — phrase the question so the needed correction is +obvious when no option fits. Copy selected labels and custom answers into +the ledger keyed by header; a dismissed question is an unanswered blocking +row: keep the gate blocked and stop. + + + + + +### Host binding: requirement questions using available host tools + +Use the host's available question tool for the batched requirement questions; +if none is available, ask in a concise message. Key each answer to its DIM/view +or requirement entry and record the user's words. Do not invent tool names or +interpret a timeout as consent. Existing explicit authorization still applies; +ask only for requirements that remain unresolved. + + + +Gate: the ask round returned answers, consent is recorded in the ledger, +`report_A.md` and `REQUIREMENTS.md` exist. Entering Role 2 without this +gate is the cardinal violation. + +**Unattended:** the gate is a self-review. Re-read `report_A.md` and `REQUIREMENTS.md` against the sheet, write one +paragraph of decision with the open risks, and go on only when every required field is filled (a recorded assumption +fills a field; an empty one does not). Entering Role 2 without it is still the cardinal violation. diff --git a/docs/skill/references/workflows/drawing-reconstruction/role-2-master-planner.md b/docs/skill/references/workflows/drawing-reconstruction/role-2-master-planner.md new file mode 100644 index 00000000..e220d542 --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/role-2-master-planner.md @@ -0,0 +1,72 @@ +# Drawing Reconstruction: Role 2, Master Planner + +Part of `workflows/drawing-reconstruction.md`. Load when Role 2 starts. + +## Role 2 — Master Planner + +From `report_A.md` and `REQUIREMENTS.md` only — no API detail, no +step-level modeling. + +1. **Feature inference.** Build the feature hypothesis list: one entry per + inferred feature (base body, segments, bores, groove lattices, bosses, + lugs, modifiers), each citing its evidence (`DIM-xxx` ids, view-list + entries, 〔归属〕 conclusions) and carrying a confidence mark. Check + `scad.std.gear` / `scad.std.bearing` before hand-modeling anything + standard-like. Function/purpose narrative stays here and in the user + conversation — it never re-enters the parse report. +2. **Stage plan.** Design the construction as `S1..Sn`: base body first, + then major segments, feature groups as their own late stages (pattern + lattices after their prototype feature is proven), modifiers last. Pair + every stage with the DIM-id subset it must satisfy. +3. Write `BUILD_PLAN.md`: stage output boundaries, datum story, DIM-id + mapping, hypothesis list. Role 3 appends verification contracts; Role 4 + appends modeling hypotheses while the loop runs. +4. Initialize the TODO tool with phases `Requirements / Plan / Build & + Verify / Roundtrip / Export`; per stage create `plan verifier`, `model`, + `run verifier` items; create the roundtrip tasks + (`roundtrip: generate sheet`, `roundtrip: parse report B`, + `roundtrip: diff and triage`). Mark Requirements and Plan done; start + the first stage's `plan verifier`. + +Present the plan in ≤10 lines. No approval gate; enter the Role 3/4 loop. + + + +### Host binding: TODO tool (omp) + +Tool `todo`: `init(list=[{phase, items[]}])`, `start(task)`, `done(task)`, +`append(phase, items[])`, `view`. Task strings are stable identifiers — +quote them exactly. Keep the stage's `model` item pending until its +`plan verifier` artifact exists; a failed `run verifier` opens a +`repair:` item for the owner; never advance while an owner is open. Batch +`todo` calls with real work in the same turn. + + + + + +### Host binding: TODO tool (opencode) + +Tool `todowrite`: submit the whole list every call as +`todos=[{content, status, priority}]` with statuses +`pending / in_progress / completed / cancelled`. `content` strings are the +stable identifiers — quote them exactly (`S1 plan verifier`, +`repair: S1 verifier bore depth`, `roundtrip: diff and triage`). Exactly one +item `in_progress` at a time; mark `completed` only at the real gate; a +blocked item stays `in_progress` with a follow-up item recording the +blocker. Keep the stage's `model` pending until its `plan verifier` +artifact exists; a failed `run verifier` opens a `repair:` item for the +owner. + + + + + +### Host binding: plan and checklist state + +Use the host's available plan/TODO tool. If none exists, maintain the same stage +checklist in `BUILD_PLAN.md`, updating the artifact after each real result. +Keep `plan verifier`, `model`, `run verifier` and any `repair:` items distinct; +complete them only when the corresponding evidence exists. + + diff --git a/docs/skill/references/workflows/drawing-reconstruction/role-3-verifier-planner.md b/docs/skill/references/workflows/drawing-reconstruction/role-3-verifier-planner.md new file mode 100644 index 00000000..06de5f0c --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/role-3-verifier-planner.md @@ -0,0 +1,113 @@ +# Drawing Reconstruction: Role 3, Verifier Planner + +Part of `workflows/drawing-reconstruction.md`. Load when Role 3 starts (before every stage's modeling work). + +## Role 3 — Verifier Planner + +Runs before every stage's modeling work and owns the roundtrip contract. +This is "tests before implementation" applied to sheet evidence. + +For the current `S` stage, in order: + +1. **Stage output**: name the substructure, its datum relationship, and the + `DIM-xxx` subset it must satisfy; state what is deliberately out of + scope. +2. **Acceptance criteria** from the ledger subset: dimensions with their + tolerance bands, required topology (face/hole/axis counts), interfaces + with already-built geometry, BREP validity and single-solid expectations, + visual criteria where a view must confirm attachment. + Apply the scope restriction in `workflows/drawing-reconstruction.md` when planning the verifier: no center-of-mass, + mass, inertia, or high-precision volume-equivalence checks without a separate + user requirement. Record the requirement/validity condition and threshold + basis for each blocking assertion; do not introduce gates during debugging. + For every DIM write a measurement contract before modeling: feature/geometry + selection, datum, direction, section origin/normal/basis, algorithm, sampling + configuration, accuracy class, error-bound evidence and tolerance basis. + Verify true circle geometry for diameters; use continuous extrema for envelopes. + Define wall thickness as minimum boundary gap, radial thickness, or material + length along a specified line before selecting the algorithm. A fitted circle + is only a candidate; sampled nesting is only a pair-selection candidate. + Neither fit residual, intersection `tolerance`, nor additional sampling alone + establishes the total error bound. Record convergence checks as such. + An approximate value with reliable bound e passes only when [value-e,value+e] + is wholly inside the tolerance band. Boundary overlap or unknown error is + `pending`; refine the algorithm or obtain a supported uncertainty assessment. + Cite any default tolerance actually specified by the sheet; otherwise record + missing tolerance basis and keep conformance pending, never invent a standard. + Unreliable fits or measurements stay pending. They cannot justify changing + CAD dimensions. For circle candidates record residuals, angular coverage, + conditioning, closure and actual edge types; a local arc, gapped contour or + nearly collinear sample set cannot establish a full circular feature. +3. **Deterministic checks**: iterate hypothesis snippets in the execution + environment; good and bad probes must show each check separates pass + from fail. QL-only topology enumeration (plural getters take an index or + are driven through `ql.().resolve`). Reject checks that merely + print a plausible number. +4. **Executable verification script** with explicit assertions and named + measurements; append `Verifier contract: / Criteria: <...> / + Script: ` to `BUILD_PLAN.md` with hypothesis evidence and known-bad + proof. Complete the stage's `plan verifier` TODO; only then may `model` + start. After Role 4 finishes, run the exact prepared script; only its + passing output closes the stage. +5. **Roundtrip contract** (written once, before S1): the coverage table + mapping every ledger `DIM-xxx` to its verification channel — a generated + sheet dimension where the drawing engine can express it, a + **section-evidence render** where the drawing's section views express it + (cut plane from the view list, measured dimensions from + `measure_model_section_rdimensions`, annotated render via + `render_model_section_rpath`, compared against the drawing's viewport by + you), or direct QL model measurement otherwise; + for each section view, a section-evidence task (plane spec, expected + DIM-id set, provenance viewport); the sheet declaration plan derived + from report A's view list; the pass criterion (every parameter within its + original tolerance band with sufficient accuracy; defaults only when the sheet + explicitly supplies a standard/class). + +Role 3 may prepare visual verification (named views, regions, verdict +fields for the isolated reviewer) but never certifies its own renders. + +**Unattended:** the isolated reviewer is replaced by a fresh-context check of your own against the written criteria +(a subagent if the host offers one); Role 3 still never certifies renders in the context that generated them, so the +check reads only the artifacts and the criteria. The verdict is quoted and listed as not independent in the report. + + + +### Host binding: persistent REPL and TODO state machine (omp) + +Use the persistent `eval` tool (language `py`) for hypothesis snippets, +known-bad probes, and the verification scripts; keep cells incremental. +Never treat a modeling success message as verification: invoke the prepared +verifier and record its output. Drive the stage gates through `todo` +exactly as declared in Role 2; close `plan verifier` only when contract, +executable check, hypothesis evidence, and known-bad proof exist; after +Role 4's run, start and complete `run verifier` on the script's real output. +On failure do not rewrite the criterion to clear the list — open +`repair: ` for the verifier or the model and repeat the loop. + + + + + +### Host binding: file-based hypothesis loop (opencode) + +No persistent REPL: every Python run is a fresh process. Keep hypothesis +snippets, known-bad probes, and final checks as numbered files under +`/verify/` (`s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`), +run them via `bash`, and persist intermediate state to files (JSON, +pickle, or captured packages) between runs. The script file is the +evidence. Gate the stages through `todowrite` with the exact task strings; +`todowrite`'s echoed list is the re-grounding source after any interruption. + + + + + +### Host binding: file-based verification + +No persistent REPL is assumed. Write hypotheses, known-bad probes and final +checks under `/verify/`, and execute them through the available shell +or execution tool. Retain scripts and JSON results so the stage can resume from +files. Update the plan/checklist from actual outputs; a failed check reopens its +owning artifact and cannot be cleared by weakening the criterion. + + diff --git a/docs/skill/references/workflows/drawing-reconstruction/role-4-detail-builder.md b/docs/skill/references/workflows/drawing-reconstruction/role-4-detail-builder.md new file mode 100644 index 00000000..aece1db8 --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/role-4-detail-builder.md @@ -0,0 +1,70 @@ +# Drawing Reconstruction: Role 4, Detail Modeling Planner & Builder + +Part of `workflows/drawing-reconstruction.md`. Load when a stage's modeling starts. + +## Role 4 — Detail Modeling Planner & Builder + +Starts only after the stage's verifier contract and executable check exist. + +1. Read the contract, then choose construction from the geometry: profile + and sketch first when they control the shape (constrained sketches, + promotion, `extrude/revolve/loft/sweep_rsolid`); documented primitives + when they map directly; named feature solids combined deliberately with + `union/cut/intersect_rsolid` (positive overlap, tool overshoot); patterns + only after one representative feature is proven; topology-sensitive + features (fillet/chamfer/shell) last. The ladder expresses intent — it + is not an API whitelist; read the exact API page before every first + call. +2. Name every construction step for its part role or feature + (`mounting_base`, `port_bore_tool`, `front_flange`), never its creation + order. Attach `role.*` / `feature.*` / `tool.*` tags; numeric facts in + metadata, not tags. +3. Probe the construction on minimal solids before editing the part source; + print types, counts, volumes, bounds as the contract requires; append + `Hypothesis / Result / Adopted` rows to `BUILD_PLAN.md`. +4. Edit the part source with keyword arguments, named parameters, FTC block + structure, and boundary comments; selection cards before every + fillet/chamfer. +5. Run the source and hand the stage geometry to Role 3. The stage passes + only when all assertions, measurements, and required visual verdicts + pass. On failure: no weakened checks — determine the owner (contract or + model), open a `repair:` item, repeat the loop. + + + +### Host binding: persistent REPL (omp) + +Hypothesis construction lives in `eval` (names survive across calls); the +real source run lives in bash. Close `S model` only after the source run +completes, then start `S run verifier` for Role 3. + + + + + +### Host binding: file-based hypothesis loop (opencode) + +Run incremental construction hypotheses as numbered scripts under +`/hypotheses/` via `bash`; carry geometry between probes by +writing files (pickle/JSON/`capture`) and reloading, or by re-materializing +from the part source. The source run itself also runs in `bash`. A modeling +success message is never the verification result. + + + + +**Unattended:** the user's explicit review is not available. Use the fresh-context check of the standing discipline +(`workflows/drawing-reconstruction.md`), store its identity, quoted observations and reviewed-file hashes, and list it +as not independent in the report; the visual gate stays pending until that written verdict exists. + + +### Host binding: modeling execution and independent image review + +Run the part source with the available execution tool and preserve the stage's +verification outputs. For image judgments, use an available isolated reviewer +with the render paths and written criteria, or the user's explicit review when +no such tool exists. The context that generated the image cannot provide its +own acceptance verdict. Store reviewer identity, result and reviewed artifact +hashes; lack of a reviewer leaves the visual gate pending. + + diff --git a/docs/skill/references/workflows/drawing-reconstruction/role-5-exporter.md b/docs/skill/references/workflows/drawing-reconstruction/role-5-exporter.md new file mode 100644 index 00000000..f2072c5c --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/role-5-exporter.md @@ -0,0 +1,22 @@ +# Drawing Reconstruction: Role 5, Exporter + +Part of `workflows/drawing-reconstruction.md`. Load when the roundtrip has passed. + +## Role 5 — Exporter + +Read the export rows from `REQUIREMENTS.md`. Durable canonical delivery is +`sca run .py --out out/.scadpkg` (or +`capture(run.definition, ...)` after `run_notebook`); external formats through +`domains/export-and-translation.md`. Post-export gate: re-open the package +in a fresh process and confirm definition/solid counts; list every produced +file with size. + +Final delivery ⛔: evidence pack — `report_A.md`, `report_B.md`, the diff +table, calibration records, provenance viewports, sheet B renders — plus +assumptions made, checks not run, and the process-note remarks from the +technical-requirements classification. Record the user's verdict; a +rejection routes back through the role map to the owning artifact. + +**Unattended:** the final-delivery gate binds the evidence pack, not a person: attach it to `report.md` together with +the assumptions, the checks not run and the process-note remarks, and state the result of the roundtrip. No verdict is +recorded as an approval; the service's own validation is the verdict. diff --git a/docs/skill/references/workflows/drawing-reconstruction/roundtrip-acceptance.md b/docs/skill/references/workflows/drawing-reconstruction/roundtrip-acceptance.md new file mode 100644 index 00000000..e97f1863 --- /dev/null +++ b/docs/skill/references/workflows/drawing-reconstruction/roundtrip-acceptance.md @@ -0,0 +1,118 @@ +# Drawing Reconstruction: Roundtrip Acceptance + +Part of `workflows/drawing-reconstruction.md`. Load when the last stage closes and the model is drawn back into sheet B. + +## Drawing-roundtrip acceptance + +Owned by Role 3, executed after the last stage closes. This loop replaces +impression with evidence: the model must regenerate the sheet's content. + +1. **Generate sheet B.** From the validated model, drive the engineering- + drawing engine (`domains/engineering-drawing.md`) in batch: declare the + view set from report A's view list (subset the engine supports), with + dimensions covering the sheet-expressible ledger parameters. Read + `report()`; declaration-level problems are re-declared, never + action-patched. Export the DXF/PDF plus rendered PNGs. + Build annotations from verified measurement records. Section evidence uses + `build_section_annotation_rrecord`; native sheet dimensions require equivalent + bindings to their model-measurement records. Copying A's nominal values is not + a model measurement. + Bind `DIM ID → feature/contours → section definition → measurement ID → model hash`. + Independently bind `measurement_contract` from the ledger: dimension kind, + direction, definition, line point, units and coordinate space. A contour ID + shared by width and height cannot substitute for this contract. + Before rendering, `validate_section_annotations_rreport` independently reruns + geometry measurement and checks values, target IDs, section/configuration and + current model hash. A model or section change invalidates the old evidence + (conservative invalidation of all model evidence is acceptable). Preserve + unrounded measured values for acceptance; configure displayed decimals from + the tolerance band. Reject a display step wider than the band, rounding error + greater than half its width, or a displayed number outside it. Keep + `measured=999`, wrong-axis, wrong-definition, coarse-display and stale-model cases as + required negative tests. Raw rendering remains permitted but cannot pass this gate. +2. **Generate section evidence.** For every section view in report A's view + list, cut the model at the recorded plane (`measure_model_section_rdimensions`), + verify the ledger-matching dimension candidates and their accuracy, and render the annotated + section (`render_model_section_rpath`). The annotated values are model + measurements only when their source binding passes independent validation. + Declare section_strategy explicitly (auto/material/edges) and archive the + method actually used. Formal section evidence requires the section_checks + contract: independently resolved origin, normal and local axes, expected solid, + material-face and hole counts, representative material/void points, point + tolerance and its basis. Check model validity/closure, section closure, valid + material faces and topological outer/inner wires. Classify each point against + the original solid, material-face intersection and displayed polygons. Cover + every material face and every hole; boundary/uncertain points remain pending. + An image containing open or incomplete contours is diagnostic output only. + First verify the section definition, intersection and display. Modify the + model only when independent solid checks establish a geometry error supported + by the drawing; a rendering failure never establishes that by itself. + Record **layout readability** (legibility, clipping, overlap) separately from + **feature association** (anchor, target geometry, leader path, text box). + Automatic placeholder anchors are not association evidence. Tables require + feature numbers plus view/detail references that permit checking the mapping. + Archive local axes, final rotation/scale, canvas and pixel transforms, text + boxes and leader paths; test interior-point forward/inverse transforms. + Visual review records must retain the DIM/measurement/model/section IDs and + SHA-256 of the actual reviewed PNG and sidecar. The acceptance script compares + these with current files and annotation content; it must never overwrite a + review's old IDs. Changes to anchors, displayed precision, render layout or + either artifact require review of the new artifact even if model IDs agree. + Compare each render against the drawing's own + viewport crop (both are yours) and emit per-DIM verdicts + (`consistent` / `different` / `unreviewable` + reasons); hand both images + to the isolated reviewer when visual arbitration is required. + +**Unattended:** the isolated reviewer is the fresh-context check described in the standing discipline of +`workflows/drawing-reconstruction.md`; per-DIM verdicts quote what was observed in both images, and the report lists +the arbitration as not independent. +3. **Parse report B blind.** Re-run Role 1's SOP (steps 0–7) on sheet B and + write `drawing_work/report_B.md` in the same skeleton — without reading + report A's values. B carries its own `DIM-xxx` ids for its own sheet. + Blind reading tests communication. It never substitutes for model geometry + measurement; A/B numerical agreement must still have a model-derived source. +4. **Diff.** Join verdicts to A's ledger parameters (geometry identity, + not id text): sheet-expressible parameters join against report B's + entries, section-expressible parameters against the section-evidence + verdicts. Verdict per parameter: `consistent` (within the original + tolerance band; unspecified tolerances per the sheet's default + standard), `different`, or `not-expressible`. The diff table (parameter, + A value, B value, verdict, channel) is a deliverable. +5. **Not-expressible fallback.** Parameters neither channel can carry + (hatch-level geometry, features invisible in every resolved view) + are verified by direct QL measurement of the model against the ledger — + same tolerance criterion, different evidence channel, recorded in the + coverage table. +6. **Triage every `different`.** Re-open the original drawing evidence + (re-render the provenance viewport) before deciding: + - **Sheet-generation artifact** — `report()` warnings, engine + limitations, projection placement faults: fix the declaration, + regenerate B, re-diff. The model is never touched for this. + - **Section-plane artifact** — the cut plane was resolved wrong (R1–R4 + misapplied): correct the plane spec in report A's view list, re-render + the section evidence, re-diff. The model is never touched for this. + - **Parse error in report A** — the original evidence contradicts the + ledger entry: correct `report_A.md` (tier marks intact), re-derive the + affected hypotheses and stages, and re-model through the owning stages. + - **Model error** — the evidence supports the ledger and the model + measures otherwise: repair the owning source parameter (FTC block), + re-run the stage verifier, then regenerate B. +7. **Convergence.** Two full rounds without convergence → ⛔ stop and ask + the user with the diff table and the triage evidence. Blind parsing, + section evidence, and delegated visual review apply to every round — no + shortcuts on retries. + +**Unattended:** no ask. After two non-converging rounds stop the repair loop and keep the diff table and the triage +evidence. Every non-converged DIM stays `different` or `pending` in `report.md`, with no pass claim, and no band is +relaxed to converge. Role 5 does not run as an accepted delivery: the part source, the reports and the diff table are +the deliverable. Blind parsing, section evidence and the fresh-context review still apply to every round. + +Pass: each DIM separately passes (1) original interpretation — value, tolerance, +association and coordinate evidence; (2) model measurement — actual geometry and +adequate accuracy; (3) output expression — the matching measurement supplied the +number and the feature association is correct. The isolated reviewer also passes +layout readability and feature association separately for sheet B and section +pairs. `assess_dimension_rverdict` records the three gates. Coverage must enumerate +value verification, association verification and layout review independently; +missing evidence cannot be hidden in a combined pass count. A fallback measurement +can satisfy geometry, but cannot certify an absent or incorrect output expression. diff --git a/docs/skill/references/workflows/engineering-drawing.md b/docs/skill/references/workflows/engineering-drawing.md index 37d9cfc4..9f8b3978 100644 --- a/docs/skill/references/workflows/engineering-drawing.md +++ b/docs/skill/references/workflows/engineering-drawing.md @@ -77,6 +77,11 @@ this gate closes. defaults first (`domains/requirement-refinement.md`). Record the rest in the Ask-or-Record ledger with basis. + **Unattended:** no ask round. Decide each drawing-level choice from the + model's facts (the conservative default of `domains/requirement-refinement.md` + when the evidence ties) and record it in the assumption ledger inside + `DRAWING.md`: `value / evidence / how to change it / the check that would show it wrong`. + Write `DRAWING.md` with: scale; per-sheet view roster; datum scheme; dimension roster seeds (parameter → kind/semantic guess); R5 ledger skeleton (every parameter → dimension carrier or notes/table); open assumptions. @@ -84,6 +89,11 @@ dimension roster seeds (parameter → kind/semantic guess); R5 ledger skeleton Gate: the ask round returned and consent is recorded. Entering Role 2 without this gate is the cardinal violation. +**Unattended:** the gate is a self-review. Re-read `DRAWING.md` against the +request and the model's facts, write one paragraph of decision with the open +risks, and go on only when every required field is filled (a recorded +assumption fills a field; an empty one does not). + ## Role 2 — Sheet Planner From `DRAWING.md` only — no API detail, no coordinates. @@ -149,6 +159,11 @@ Rendered-PNG-only judgment, delegated in the same spirit as image verdicts in `workflows/single-part-modeling.md`: the reviewer sees the artifacts, not the reasoning. +**Unattended:** a fresh-context check of your own replaces the delegated +reviewer (a subagent if the host offers one): it reads only the PNG and the +check list below, and its structured verdict quotes what it observed per item. +The report says the review was not independent. + 1. Read the PNG rendered from the final DXF (both sheets if multiple). 2. Check, in order: views aligned as planned; hidden lines (虚线) and center lines present; arrows touch their geometry; texts sit clear of lines; @@ -193,3 +208,35 @@ A failed item becomes a `repair:` item for Role 3. Never patch the DXF. linear dims only; radius labels same-side outward): restructure the view set or record the limitation; do not simulate missing features with leader-note spam. + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/engineering-drawing.md` | standing discipline, role map, Role 1 (this page) | +| `domains/engineering-drawing.md` | solver rules and engine boundaries; this workflow does not repeat them | +| `domains/requirement-refinement.md` | Role 1 decides or asks the drawing-level choices | + +## Read when + +| When | File | +| --- | --- | +| Role 2 fixes the datum scheme | `discipline/datums-and-coordinate-systems.md` | +| the model's requirements or build plan exist | `domains/part-modeling.md` | +| Role 4 compares with the image-verdict practice | `workflows/single-part-modeling.md` | +| any part source is authored or edited to make geometry final | `discipline/feature-tree-convention.md` | +| a sheet fails twice for the same cause | `discipline/failure-and-repair.md` | + +## Unattended mode + +Decide the mode first (`discipline/operating-modes.md`). In unattended mode never ask; a gate still binds its artifact +(`DRAWING.md`, a solver-clean `report()`, a written verdict) and the result goes into `report.md`. + +| Interactive gate | Unattended replacement | +| --- | --- | +| Role map and Role 1 consent gate (⛔ user consent received) | Self-review gate on `DRAWING.md`: one paragraph of decision with open risks, every required field filled | +| Role 1 step 2 decide-or-ask (scale, views, datums, critical parameters, notes/table split) | Decide from the model's facts and record each in the assumption ledger | +| Role 1 step 3 one batched ask round | No ask round; ledger rows with evidence, how to change, and the check that would show it wrong | +| Missing or unvalidated model geometry on entry | Record the blocker in the report, do the work the available evidence supports, never invent geometry or parameters | +| Role 4 delegated visual review of the PNG | Fresh-context check against the written list, quoted observations, listed as not independent in the report | +| Role 2 plan presented (no approval gate) | No change | diff --git a/docs/skill/references/workflows/export-and-translation.md b/docs/skill/references/workflows/export-and-translation.md index fe86a23c..6fffa78f 100644 --- a/docs/skill/references/workflows/export-and-translation.md +++ b/docs/skill/references/workflows/export-and-translation.md @@ -69,6 +69,8 @@ repair_routes: (`domains/export-and-translation.md` table): editable FreeCAD document → `.FCStd`; neutral exchange → AP242 `.step`; DCC/surface inspection → `.obj`; additive manufacturing → `.stl`; MuJoCo simulation → MJCF. + **Unattended:** when the request names no format, export the default + STEP/STL/OBJ and record that choice as an assumption in the report. 3. **Preflight and export from the package path**. Start with `uv run sca export .scadpkg --output-dir --check`. It checks the package and default STEP/STL/OBJ targets. Add each non-default @@ -111,6 +113,9 @@ and `validate_step_roundtrip_rdescriptor` when roundtrip validation applies. - FreeCAD/FreeCADCmd missing → `sca export --check --format fcstd` reports the missing backend before writing. Export default STEP/STL/OBJ if requested, offer STEP as the neutral fallback, and say so explicitly. + **Unattended:** do not offer; export the default STEP/STL/OBJ if requested, + record the missing backend and the fallback in the report, and never present + the fallback as the requested format. - STL/OBJ too coarse or too large → adjust deflection parameters, re-export; both formats share one tessellation pass. - MJCF joints missing → the assembly lacked explicit constraints; return to @@ -129,3 +134,33 @@ is a marimo notebook, one block per cell (`references/docs/guides/notebook-runtime.md`): run it with `sca run`, and load its product from verification scripts with `simplecadapi.runtime.run_notebook(...)`. + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/export-and-translation.md` | the command path, steps and gates (this page) | +| `domains/export-and-translation.md` | the consumer-contract table and format reports | + +## Read when + +| When | File | +| --- | --- | +| the requested formats or their reasons are unclear | `domains/requirement-refinement.md` | +| the consumer re-imports STEP | `domains/step-inspection.md` | +| MJCF joints are missing | `workflows/assembly-product-build.md` | +| the package is exported through the API instead of the CLI | `docs/guides/product-export.md` | +| a part source is edited to make the package current | `discipline/feature-tree-convention.md` | +| a part source is run or loaded by a script | `docs/guides/notebook-runtime.md` | + +## Unattended mode + +Decide the mode first (`discipline/operating-modes.md`). This workflow has no consent gate or reviewer; its few +decisions are recorded in the report. + +| Interactive gate | Unattended replacement | +| --- | --- | +| Step 2: which formats and why (Ask-or-Record in `domains/requirement-refinement.md`) | Take the formats the request names, else the default STEP/STL/OBJ; ledger the choice | +| Failure route: backend missing, offer STEP as the neutral fallback | Export the default targets if requested, record the missing backend and the fallback in the report, no silent substitution | +| Package not current or the model needs work (route to the owning modeling workflow) | Record the blocker in the report, export nothing as validated, never invent geometry | +| Closing report to the person | The report (`discipline/operating-modes.md`): file paths, per-format facts, parameters, unavailable backends, checks not run | diff --git a/docs/skill/references/workflows/scan-reconstruction.md b/docs/skill/references/workflows/scan-reconstruction.md new file mode 100644 index 00000000..cadc58d4 --- /dev/null +++ b/docs/skill/references/workflows/scan-reconstruction.md @@ -0,0 +1,191 @@ +# Workflow: Scan Reconstruction + +Rebuild an editable SimpleCAD model from a physical part that exists only as a mesh (STL, OBJ, PLY), a point cloud, or +photos/images. The model is judged against the scan by tolerance, never by topology identity. This workflow is very +likely run unattended; the report and the evidence tiers are first-class. + +## Goal and scope + +Use when the deliverable is an editable, replayable model of a part and the only source is scan data or pictures. Not +for a STEP/BREP source (`workflows/step-reconstruction.md`), not for a vector-PDF drawing +(`workflows/drawing-reconstruction.md`), not for exporting a model to STL (`workflows/export-and-translation.md`). + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/scan-reconstruction.md` | this page: what the SDK supports, evidence tiers, steps, gates | +| `domains/step-inspection.md` | the inspection and comparison primitives that run on the candidate | +| `domains/part-modeling.md` | building the candidate part | + +## Read when + +| When | File | +| --- | --- | +| step 1: the request leaves units, purpose or fidelity open | `domains/requirement-refinement.md`, `discipline/requirement-and-cad-brief.md` | +| step 2: choosing the part-local frame and datums | `discipline/datums-and-coordinate-systems.md` | +| step 4: inferring feature families and provenance rows | `workflows/step-reconstruction.md`, `docs/guides/reconstruction-agent-strategy.md` | +| step 5: authoring the candidate source | `workflows/single-part-modeling.md`, `discipline/feature-tree-convention.md`, `docs/guides/notebook-runtime.md` | +| step 5: constraints represent the evidence | `domains/sketch-and-features.md` | +| step 5: the shape is freeform (lofts, B-spline sections, point grids) | `docs/api/loft_rsolid.md`, `docs/api/fit_cubic_bspline_control_points.md`, `docs/api/fit_point_grid_rface.md`, `docs/api/sew_faces_rshell.md`, `docs/api/make_solid_from_shell_rsolid.md` | +| step 6: comparing, or a comparison fails | `discipline/geometric-validation.md`, `discipline/failure-and-repair.md` | +| the part is a standard component | `domains/standard-parts.md` | +| the scan is one part of a product | `workflows/assembly-product-build.md` | +| delivering a package or STL | `docs/guides/product-export.md`, `domains/export-and-translation.md` | + +## Unattended mode + +| Interactive gate | Unattended replacement | +| --- | --- | +| Step 1: ask the units, the scale, the part's purpose, which features matter, the tolerance, parametric model vs shape match | Record each as an assumption in the ledger. Units: the file's header if it has one, else the unit that makes the part's size plausible, tier `[assumed]`. Tolerance: derived from the scan's own resolution (step 1), stated before any comparison | +| Step 1: consent gate on the brief | Self-review: re-read the brief against the request and the evidence, write one paragraph of decision with the open risks; go on only when every field is filled (a recorded assumption fills a field) | +| The source is missing, unreadable, or has no scale (photos, unitless point cloud) | Record the blocker in the report; do the strongest work the evidence supports and say which parts it supports; never invent dimensions or a scale | +| Step 3: noise or a bump the scan cannot tell from a feature | Treat as a feature only when it appears in at least two independent measurements; otherwise omit it and list it as uncertain | +| Step 4/5: a choice between analytic features and a freeform surface | Take the one that meets the tolerance and is easier to edit; record the other in the plan | +| Step 6: a person looks at the comparison renders | Look yourself: name the views, what you looked for, quote the observation; the numeric comparison still decides | +| A second reviewer of the verdict | Fresh-context checklist pass quoting observations (subagent if offered); otherwise the report says the review was not independent | +| Mesh libraries (`trimesh`, `numpy-stl`) are not installed | Use the numpy intake in step 1; the report says which checks were done by hand-written script | +| The same comparison fails twice for the same cause | Change the feature hypothesis or redefine the check in the report; never loosen the tolerance silently (`discipline/failure-and-repair.md`) | +| Closing message to the person | The report (step 8) | + +## What the SDK supports and what it does not + +Checked against `src/simplecadapi` and `references/docs/api`. Where a row says "not supported", no function exists; do not +guess one. + +| Step | Supported by the SDK | Not supported | +| --- | --- | --- | +| Read a mesh (STL/OBJ/PLY) or point cloud | nothing | no mesh or point-cloud importer; STL/OBJ are export-only (`export_product_package_to_stl`, `export_product_package_to_obj`). Read the file with your own numpy code (step 1). `trimesh` and `numpy-stl` are in the dev dependency group only, so check with `python -c "import trimesh"` before relying on them | +| Read photos or images | nothing | no image-to-geometry, no photogrammetry. `inspect.drawing` reads vector PDF only; raster images are "no supported route" (`domains/drawing-inspection.md`). Looking at an image is your host's vision, not an SDK step | +| Mesh to BREP, mesh to STEP, remeshing, smoothing, hole filling of a mesh | nothing | none of it. A mesh cannot become a solid by an SDK call. `fill_holes_rshell` and `sew_faces_rshell` act on BREP faces and shells, not on mesh triangles | +| Sections, dimensions, fits of the scan | nothing | the section and measurement tools (`inspect_section_rdescriptor`, `measure_model_section_rdimensions`, `fit_face_analytic_rdescriptor`) take a BREP model, STEP or shape, never a mesh. Cut, measure and fit the scan with your own numpy code | +| Curve and surface fitting from sampled points (your own samples) | `fit_cubic_bspline_control_points` (ordered 2D/3D samples to a cubic B-spline with an error), `fit_point_grid_rface` (rectangular 3D grid to a B-spline face), `make_spline_rwire`, `make_interpolated_spline_rwire`, `add_bspline_rsketch` | scattered (unordered) point sets: order and grid them yourself first | +| Build solids from the evidence | `extrude_rsolid`, `revolve_rsolid`, `sweep_rsolid`, `loft_rsolid`, `sweep_multisection_rsolid`, booleans, `make_polyline_rwire`, `make_face_from_wire_rface`, `sew_faces_rshell`, `make_solid_from_shell_rsolid`, sketches (`domains/part-modeling.md`, `domains/sketch-and-features.md`) | a faceted solid from thousands of triangles is composable from the polyline, face, sew and solid functions above, but it is neither editable nor a reconstruction; do not deliver it as one | +| Inspect and render the candidate | `inspect_shape_rbrepinspection`, `measure_shape_mass_rtuple`, `render_shape_views_rpath`, `render_screenshot_rpath`; after export, the STEP tools of `domains/step-inspection.md` | rendering the scan itself (use matplotlib, a core dependency, in your own script) | +| Compare candidate with scan | export the candidate to STL (`export_product_package_to_stl`) and compare in your own script (step 6) | no mesh-vs-BREP distance, no scan-vs-model section diff, no `compare_*` tool that accepts a mesh | +| Delivery | `sca run`, `capture`, package and exports (`docs/guides/product-export.md`) | | + +## Evidence tiers + +Every dimension, feature and statement in the brief, the provenance rows and the report carries one tier mark. They +follow the drawing workflow's idea (what you read, what you attributed, what you derived) with scan-specific meaning. + +| Mark | Tier | Meaning | How to cite | +| --- | --- | --- | --- | +| `[measured]` | measured | a number your script computed from the file: bounding box, a section width, a hole diameter from a vertex ring, mesh volume | script path, the plane or vertex set, the printed value | +| `[fitted]` | fitted | a parameter from a fit with a residual: cylinder radius, plane normal, spline control points | the fit, the point count, the residual and the tolerance it was judged against | +| `[inferred]` | inferred | design intent read off the shape: symmetry, a round number, a standard size, a pattern count | the measurements it rests on and the check that could show it wrong | +| `[assumed]` | assumed | no evidence: units without a header, a hidden side, any photo dimension without a known reference, scale | the ledger row: `value / evidence / how to change it / the check that would show it wrong` | + +Rules: a photo gives `[assumed]` dimensions unless the request supplies a reference length, and then at best +`[inferred]`; a point cloud gives no volume; only `[measured]` and `[fitted]` values may close a gate; rounding a measured +value to a design value is `[inferred]`, and the measured value stays in the row. + +## Steps + +1. **Intake and brief.** Decide the mode (`discipline/operating-modes.md`). Read the source with your own script and print + facts before any interpretation: format, triangle and vertex counts, bounding box, units (STL has none; OBJ rarely; PLY + may say in its header), open and non-manifold edges, signed volume, whether normals agree. A working numpy reader for + binary and ASCII STL, tested on a box: + + ```python + """Facts about a mesh file, by plain numpy: nothing here is SimpleCADAPI.""" + import sys + import numpy as np + + def read_stl(path): + raw = open(path, "rb").read() + n = int.from_bytes(raw[80:84], "little") + if len(raw) == 84 + 50 * n: # binary + rec = np.frombuffer(raw[84:], dtype=np.dtype([("n", "i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6.0 + print("triangles", len(tri), "vertices", len(uniq)) + print("bbox min", uniq.min(0), "max", uniq.max(0), "size", np.ptp(uniq, axis=0)) + print("edges used once (open)", int((cnt == 1).sum()), "used >2 (non-manifold)", int((cnt > 2).sum())) + print("signed volume", vol, "(valid only if watertight and consistently oriented)") + ``` + + The brief states the purpose, units and how they were decided, the tolerance, what fidelity is wanted (editable + parametric model, or shape match), and the features that matter. Derive the tolerance from the scan, not from hope: + no tighter than the vertex spacing or the scanner's stated accuracy, and write it down before step 6. + Interactive: ask once, batched, then consent gate on the brief. **Unattended:** record every open field in the ledger + and run the self-review gate (table above). +2. **Fix the frame.** Choose the part-local frame and datums (`discipline/datums-and-coordinate-systems.md`): align to + the bounding box or to the principal axes only when the shape supports it, and put the origin on a datum the part + defines (a bore axis, a mating face), not on the scan's arbitrary origin. Record the transform that maps scan + coordinates to part coordinates. Photos: no frame exists until a known length and a view direction do; say so. +3. **Measure the scan.** With your own script, cut the mesh at planes through the datums and at spacing that resolves the + smallest feature you need (triangle-plane intersection, then chain segments into contours); print contour extents, + enclosed areas, hole diameters and centres, wall thickness. Fit analytic carriers to vertex clusters (plane, cylinder, + sphere) with residuals. Store the arrays in files, not in the conversation. Every number gets a tier mark. + **Unattended:** a feature counts only when two independent measurements show it; the rest is listed as uncertain. +4. **Infer the feature tree.** Reuse the family table of `workflows/step-reconstruction.md` (revolve, extrude, sweep, + loft, pattern, mixed) and write one provenance row per region: observed section behaviour, proposed operation and + axis, tier, confidence, the check that could falsify it. Verify repetition and symmetry on at least two units before + patterning. A region the scan cannot resolve (hidden, occluded, below the tolerance) is a gap, recorded as such. + **Unattended:** between analytic features and a freeform surface take the one that meets the tolerance and is easier + to change; record the other. +5. **Build the candidate** via `workflows/single-part-modeling.md` (block structure per + `discipline/feature-tree-convention.md`): analytic features first, named parameters for the measured dimensions, + freeform sections only where the evidence is freeform. Overshoot through-cut tools; validate one representative + feature before patterning. Run it with `sca run` and load its product with `run_notebook`. +6. **Compare with the scan, in your own script.** Export the candidate (`sca run .py --out out/.scadpkg`, + then `export_product_package_to_stl` with a deflection well below the tolerance) and read the STL with the step-1 + reader. Compare, with the tolerance fixed in step 1: + - bounding box and volume (candidate volume from `measure_shape_mass_rtuple` or the package; scan volume only if + watertight); + - the step-3 section planes: contour extents and areas, scan against candidate; + - sampled distance from scan vertices to the candidate's tessellation, as max, mean and the share beyond tolerance, + and the regions where it is beyond tolerance. + Print one table, one row per check. Render the candidate with `render_shape_views_rpath` and the scan with + matplotlib from the same views. **Unattended:** look at both yourself and quote the observation. + A failed row is closed by changing the feature hypothesis, or by redefining the check in the report with the reason; + never by loosening the tolerance without saying so. Keep the best valid candidate; repeated non-improvement changes + the hypothesis instead of the parameters (`discipline/failure-and-repair.md`). +7. **Accept by tier.** Topology identity does not exist for a scan, so it is never claimed. + - **A:** every comparison row is within tolerance and every feature in the tree is `[measured]` or `[fitted]`. + - **B:** within tolerance except listed regions, or some features are `[inferred]`; each is named. + - **C:** a functional approximation with listed unrecovered regions or `[assumed]` dimensions that matter. + Photos-only and point-cloud-without-scale runs cannot reach A. The report states the tier reached and why. +8. **Report.** Unattended: write `report.md` next to the source (`discipline/operating-modes.md`) with the sections below; + interactive: the same content in the closing message. + +## Validation gates + +Each binds an artifact, in both modes. + +- The intake facts, the transform to part coordinates and the tolerance exist as printed output or files before step 3. +- Every dimension in the candidate traces to a provenance row with a tier mark; no `[assumed]` value hides in a parameter. +- The candidate replays in a fresh process (`export_model_json` then `replay_model_json`) and its STEP export passes + `validate_step_roundtrip_rdescriptor`. +- The comparison table of step 6 exists, ran against the fixed tolerance, and its failing rows are explained. +- Global similarity never overrides a locally falsified feature. + +## Failure routes + +- The scan is open, non-manifold or has no consistent orientation: volume and watertight claims are dropped (report the + numbers you could not trust); sections and fits may still be used. +- No affordable evidence for a region: record the gap; do not invent geometry and call it recovered. +- Candidate disagrees with scan in one region only: re-open that feature's hypothesis, not the whole tree. +- A needed capability is not in the table above: report it as not supported and continue with the evidence that exists. + +## Report + +Unattended default `report.md`; it lists, as `discipline/operating-modes.md` requires: + +- the assumption ledger (units, scale, tolerance, hidden regions, intent), each row with its tier; +- what was reconstructed, omitted and uncertain, one line per feature with its tier mark; +- the step-6 comparison table and the acceptance tier reached; +- which checks were done by hand-written scripts (the SDK has no mesh tools), with the script paths; +- checks not run, with the reason, and any review that was not independent; +- the commands that reproduce the result. diff --git a/docs/skill/references/workflows/single-part-modeling.md b/docs/skill/references/workflows/single-part-modeling.md index 3355c0a2..8ac9ca20 100644 --- a/docs/skill/references/workflows/single-part-modeling.md +++ b/docs/skill/references/workflows/single-part-modeling.md @@ -5,6 +5,52 @@ packaged geometry. The workflow is role-structured. Roles 1 and 2 establish the requirements and the stage plan; Roles 3 and 4 then run a strict, stage-by-stage TDD loop; Role 5 exports only after every stage gate passes. + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/single-part-modeling/role-1-requirements.md` | Role 1 procedure and its gate: nothing is built before it closes | +| `workflows/single-part-modeling/requirements-template.md` | the `REQUIREMENTS.md` fields (with the `Mode:` line and the assumption ledger) | +| `domains/requirement-refinement.md` | brief format, Ask-or-Record blocking table, input precedence | +| `discipline/requirement-and-cad-brief.md` | evidence precedence, one-question rule, brief quality gate | + +## Read when + +| When | File | +| --- | --- | +| Role 1 is about to ask (interactive): designing the batched ask payload | `workflows/single-part-modeling/role-1-ask-bindings.md` | +| Role 1 is about to read a model or a drawing as input | `domains/step-inspection.md` / `domains/drawing-inspection.md` | +| Role 2 starts | `workflows/single-part-modeling/role-2-plan.md`, `discipline/mechanical-modeling.md`, `discipline/datums-and-coordinate-systems.md` | +| Role 3 starts a stage | `workflows/single-part-modeling/role-3-verifier.md`, `workflows/single-part-modeling/role-3-host-bindings.md`, `discipline/geometric-validation.md` | +| Role 3 selects the checks, or plans a visual review | `workflows/single-part-modeling/verifier-contract-patterns.md`, `discipline/verifier-scripts.md` | +| Role 4 starts a stage | `workflows/single-part-modeling/role-4-build.md`, `domains/part-modeling.md`, `discipline/feature-ordering.md` | +| Role 4 writes a notebook source | `discipline/feature-tree-convention.md`, `discipline/notebook-cell-design.md`, `docs/guides/notebook-runtime.md` | +| a stage's profiles are sketch tier | `domains/sketch-and-features.md` | +| the requirement is a standard part (gear, bearing, fastener) | `domains/standard-parts.md` | +| a mechanism mating several standard parts | `workflows/assembly-product-build.md` | +| Role 5 starts | `domains/export-and-translation.md`, `domains/assembly-and-product.md`, `docs/guides/product-export.md` | +| a check or a build keeps failing | `discipline/failure-and-repair.md` | + +## Unattended mode + +The mode is decided once, at the start (`discipline/operating-modes.md`), and written as the first line of +`REQUIREMENTS.md`. No gate is weakened: each one still binds an artifact. Only who decides changes. + +| Interactive gate | Unattended replacement | +| --- | --- | +| "⛔ BLOCKING means stop": wait for the explicit user answer (standing discipline) | Never wait. The gate passes on an artifact: the filled brief plus a written decision paragraph. Never decide by feel | +| Role 1 ask round: shape interpretation confirmed, Ask-or-Record blocking items asked, recommended assumptions offered as ask options (Role 1 steps 1 and 2) | Record each item in the assumption ledger (`value / evidence / how to change it / the check that would show it wrong`). Shape-level facts are still described region by region and checked against the measured evidence first | +| Role 1 host ask bindings (`ask`, `question`, `AskUserQuestion`, plain-text message that stops and waits) | Skip `workflows/single-part-modeling/role-1-ask-bindings.md`. Nothing is asked and nothing is waited for | +| Role 1 exit gate "⛔ user consent received" | Self-review gate: re-read `REQUIREMENTS.md` against the request and the evidence, write one paragraph of decision with the open risks, go on only when every field is filled | +| Role 2 "plan presented" | The plan is `BUILD_PLAN.md` plus a short summary in the report. Two viable strategies: take the one that meets the requirements and is easier to change later, record the other in `BUILD_PLAN.md` | +| Role 3 / Role 4: image verdict from an isolated subagent reviewer (standing discipline, Role 3 visual contract, Role 4 step 5) | A fresh-context check of your own against the written visual contract, using a subagent if the host offers one, quoting the observation per region; if neither is possible the report says the review was not independent. A mismatch is never overridden by the context that made the render | +| Isolated-review host bindings whose fallback reviewer is the user (codex, dsh / kimicode) | The user is not asked to look at the render. Use the row above | +| Role 5 "Final delivery ⛔": evidence pack shown, the user's verdict recorded | The report (`discipline/operating-modes.md`) replaces the message: evidence pack, artifact list, assumptions, checks not run, reviews that were not independent. Your closing text approves nothing | +| A rejection at final delivery routes back through the role map | The service validates independently; a failure of your own checks still routes back to the owning artifact | +| A missing input (mesh, drawing, image) | Record the blocker in the report, do the strongest work the available evidence supports, never invent the input | +| Conflicting information between sources | Measured evidence over prose, prose over inference; record the conflict and the choice | + Standing discipline, binding for every role: - **Serial stages, no bundling.** Do not prepare a later stage's model or @@ -36,7 +82,7 @@ Standing discipline, binding for every role: brief, never left silent. Capability detail: `domains/sketch-and-features.md`. - **Feature Tree Convention.** Every part source is a marimo notebook - (`references/docs/guides/notebook-runtime.md`) authored as + (`docs/guides/notebook-runtime.md`) authored as block-structured features per `discipline/feature-tree-convention.md` (sketch → basic body op → bool → modifier, one feature per block and one block per cell, each with its boundary comment); read both before Role 4 @@ -84,1045 +130,6 @@ artifact and repeats the same Role 3/4 loop. --- -## Role 1 — Requirement Confirmer ⛔ - -Convert the request into `REQUIREMENTS.md`. No geometry, no probe builds, -no source-file writes before this role's gate closes. - -Procedure: - -1. Classify inputs (prose / reference images / drawings / mesh models ,etc.). For any image - containing reference structure: write the interpreted shape region by - region — overall form, visible features and locations, symmetry and - orientation, anything hidden or uncertain. **Describing is not - confirming:** the interpretation ends as part of ONE batched ask round - that also covers the Ask-or-Record blocking table - (`domains/requirement-refinement.md`). Shape-level facts are always - confirmed; only dimension-level defaults may be assumed once a scale - anchor exists. - For provided models(most cases are mesh), use python code to render and inspect geometry information through something like sampling / fitting, which may provide useful geometry information for you to build similar stucture / rebuild same struction in brep through simplecadapi. -2. For undetermined parameters / geometry / function / performance, propose - recommended assumptions as ask options (default first) so the user can - accept or override in one round. - - - -### Host binding: designing the Role 1 `ask` payload (omp) - -OMP already provides and executes the `ask` tool. This block defines only how -to design its input for the Requirement Confirmer. After writing the -region-by-region interpretation and the Ask-or-Record ledger, make one batched -call for all unresolved items that can block `REQUIREMENTS.md`. - -Design each question so its answer can be copied into one ledger row: - -- Ask about one decision, not one vague topic. Name the affected region, - feature, axis, or interface and state the evidence or ambiguity. -- Ask shape-level interpretation explicitly. Do not hide a disputed region in - a general "does this look right?" question. -- Separate independent decisions such as overall orientation, hole axis, - thread representation, and mounting intent. Use `multi: true` only when the - user may independently select several items from the same set. -- Make options mutually exclusive and operational: each option must say what - will be recorded and what modeling consequence follows. Do not offer - imaginary precision that the source does not support. -- Put the conservative, clearly labeled assumption first and set - `recommended: 0`; this is a recommendation, never consent. Include a - concrete correction path in the options so the user can reject or amend the - interpretation. -- Ask only blocking decisions in this round. Record inferable dimensions and - low-risk defaults as assumptions with their basis; do not burden the user - with API or verification choices owned by later roles. - -Example payload shape (the actual labels and questions must be derived from the -current part, not copied literally): - -```ts -ask({ - questions: [ - { - id: "region_c_flange_location", - question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", - options: [ - { label: "The -Y horizontal-port end", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, - { label: "The top vertical-bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } - ], - recommended: 0 - }, - { - id: "thread_representation", - question: "The drawing calls out M36-6H, but does not establish whether thread faces are needed for this deliverable. How should it be represented?", - options: [ - { label: "Nominal bore", description: "Record the callout and model the nominal bore; do not create helical faces." }, - { label: "Explicit thread geometry", description: "Record modeled thread geometry as required and plan a thread-specific verification." } - ], - recommended: 0 - }, - { - id: "mounting_intent", - question: "What mounting intent should the base holes represent?", - options: [ - { label: "Through-bolted to a flat base", description: "Record insertion along Z and verify the fastener envelope and underside clearance." }, - { label: "Tapped or blind mounting", description: "Record the hole termination and thread requirement; do not assume through holes." } - ], - recommended: 0 - } - ] -}) -``` - -Use stable machine-readable `id` values that match ledger rows. After the call, -copy the returned selected options, custom answers, notes, and any timeout -status into `REQUIREMENTS.md`; preserve the user's wording for corrections. -The gate closes only when every blocking row has an explicit answer or an -explicitly recorded assumption permitted by the requirement discipline. A -recommendation or timeout is not user consent. On cancellation, incomplete -answers, or unavailable interactive UI, keep the gate blocked and stop; never -continue by silently selecting the recommended option. - -When a question has no valid answer among the proposed options, its wording -must make clear what correction or missing requirement the user should supply. -Do not ask the user to choose an implementation detail owned by a later role; -ask only for the requirement that changes the model or its acceptance criteria. - - - - - -### Host binding: designing the Role 1 `question` payload (opencode) - -opencode provides and executes the native `question` tool, which batches all -questions in one call. This block defines only how to design its input for -the Requirement Confirmer. After writing the region-by-region interpretation -and the Ask-or-Record ledger, make one batched call for all unresolved items -that can block `REQUIREMENTS.md`. - -The tool schema differs from a generic ask; design around the differences: - -- There is no `id` field. Key each ledger row to the `header` (max 30 - characters): derive it from the stable identifier you would otherwise use - as an id, e.g. `C-flange location`, `thread representation`. -- There is no `recommended` index. Express the recommendation by putting the - conservative, clearly labeled option first and suffixing its label with - `(Recommended)`; this is a recommendation, never consent. -- A free-text "type your own answer" choice is offered automatically; do not - add your own catch-all option. Answers return as the selected option - labels (arrays when `multiple: true`). Use `multiple: true` only when the - user may independently select several items from the same set. -- Keep each `label` to a few words and put the operational consequence — - what will be recorded and what modeling follows — in `description`. - -The shared design rules still apply: one decision per question, shape-level -interpretation asked explicitly, independent decisions separated into their -own questions, mutually exclusive and operational options, a concrete -correction path in the options, and only blocking decisions in this round. - -Example payload shape (the actual labels and questions must be derived from -the current part, not copied literally): - -```ts -question({ - questions: [ - { - header: "C-flange location", - question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", - options: [ - { label: "-Y horizontal-port end (Recommended)", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, - { label: "Top vertical-bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } - ] - }, - { - header: "Thread representation", - question: "The drawing calls out M36-6H but does not establish whether thread faces are needed for this deliverable. How should it be represented?", - options: [ - { label: "Nominal bore (Recommended)", description: "Record the callout and model the nominal bore; do not create helical faces." }, - { label: "Explicit thread geometry", description: "Record modeled thread geometry as required and plan a thread-specific verification." } - ] - } - ] -}) -``` - -After the call, copy the selected labels, any typed custom answers, and the -questions' headers into `REQUIREMENTS.md` keyed by header; preserve the -user's wording for corrections. A dismissed or skipped question is an -unanswered blocking row: keep the gate blocked and stop; never continue by -silently selecting the recommended option. The gate closes only when every -blocking row has an explicit answer or an explicitly recorded assumption -permitted by the requirement discipline. - -When a question has no valid answer among the proposed options, its wording -must make clear what correction or missing requirement the user should type -as the free-text answer. Do not ask the user to choose an implementation -detail owned by a later role; ask only for the requirement that changes the -model or its acceptance criteria. - - - - - -### Host binding: designing the Role 1 `AskUserQuestion` payload (Claude Code / ZCode) - -The host provides and executes the `AskUserQuestion` tool. This block defines -only how to design its input for the Requirement Confirmer. After writing the -region-by-region interpretation and the Ask-or-Record ledger, make one batched -call for all unresolved items that can block `REQUIREMENTS.md`. - -The schema differs from a generic ask; design around the differences: - -- At most 4 questions per call. If more blocking rows remain, send the - remaining rows as further batched calls in the same turn and say why. -- `header` (max 12 characters, shown as a chip) is the ledger key: derive it - from the stable identifier you would otherwise use as an id, e.g. - `C flange`, `thread`. -- There is no `recommended` index. Put the conservative, clearly labeled - option first and suffix its label with `(Recommended)`; this is a - recommendation, never consent. -- 2–4 options per question; each option is a short `label` plus an - operational `description` saying what will be recorded and what modeling - follows. A free-text "Other" choice is offered automatically; do not add - your own catch-all option. Answers return as the selected labels plus any - typed notes. -- `multiSelect: true` only when the user may independently select several - items from the same set. - -The shared design rules still apply: one decision per question, shape-level -interpretation asked explicitly, independent decisions separated into their -own questions, mutually exclusive and operational options, a concrete -correction path in the options, and only blocking decisions in this round. -Example payload shape (the actual labels and questions must be derived from -the current part, not copied literally): - -```ts -AskUserQuestion({ - questions: [ - { - question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", - header: "C flange", - multiSelect: false, - options: [ - { label: "-Y port end (Recommended)", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, - { label: "Top bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } - ] - } - ] -}) -``` - -After the call, copy the selected labels, any typed custom answers, and notes -into `REQUIREMENTS.md` keyed by header; preserve the user's wording for -corrections. A skipped question or an empty answer is an unanswered blocking -row: keep the gate blocked and stop; never continue by silently selecting the -recommended option. The gate closes only when every blocking row has an -explicit answer or an explicitly recorded assumption permitted by the -requirement discipline. - - - - - -### Host binding: Role 1 blocking questions without a native ask tool (codex) - -codex provides no structured interactive-question tool. Keep the ledger -discipline in plain conversation: after writing the region-by-region -interpretation and the Ask-or-Record ledger, send ONE message listing every -unresolved item that can block `REQUIREMENTS.md` — one numbered question per -decision, lettered mutually exclusive options each stating the operational -consequence, the conservative interpretation first and marked -`(recommended)`, and an explicit invitation to correct or type a different -answer. Then stop: make no further tool call until the user replies. Record -the reply verbatim in `REQUIREMENTS.md` under the same ledger keys, preserving -the user's wording for corrections. A partial answer or a general "sounds -good" does not answer the open rows: re-ask exactly those rows and keep the -gate blocked; never continue by silently selecting the recommended option. -The gate closes only when every blocking row has an explicit answer or an -explicitly recorded assumption permitted by the requirement discipline. - - - - - -### Host binding: Role 1 blocking questions, conservative fallback (dsh / kimicode) - -No native interactive-question tool is assumed for this host, so use the -plain-text ledger discipline: after writing the region-by-region -interpretation and the Ask-or-Record ledger, send ONE message listing every -unresolved item that can block `REQUIREMENTS.md` — one numbered question per -decision, lettered mutually exclusive options each stating the operational -consequence, the conservative interpretation first and marked -`(recommended)`, and an explicit invitation to correct or type a different -answer. Then stop and wait for the user's reply; record it verbatim in -`REQUIREMENTS.md` under the same ledger keys. A partial answer does not -answer the open rows: re-ask them and keep the gate blocked; never continue -by silently selecting the recommended option. If this host does provide a -native ask/question tool, map this block onto it and keep the same gate -semantics: one batched round, blocking decisions only, and a recommendation -that is never consent. - - - -3. Fastened or load-bearing parts: the requirements contain the envelope - arithmetic, not just values — head diameter + shaft + wall/clearance → - minimum land diameter, boss thickness, edge distance, and lateral - offset along the insertion path. -4. Write `REQUIREMENTS.md`: - -```markdown -# Requirements: -- Inputs: -- Functional goals: -- Geometry targets: -- Units / coordinate convention: -- Named parameters (Var): -- Fastening & mounting: - Envelope math: min land / boss / offset> -- Service conditions: -- Export targets: .scadpkg always; STEP/STL/OBJ/MJCF/FCStd if requested -- Verification intent: -- Ask-or-Record ledger: | item | asked/assumed | answer/value | -``` - -Gate: the ask round returned answers, consent is recorded in the ledger, and -`REQUIREMENTS.md` exists. Entering Role 2 without this gate is the cardinal -violation. - -## Role 2 — Master Planner - -From `REQUIREMENTS.md` only — no API detail, no step-level modeling. - -1. Choose the construction strategy (profile-driven vs block-and-feature) - and fix the datum from the functional interface; one-paragraph rationale - each. -2. Design the construction as numbered stages `S1..Sn`. For each stage state - exactly what new substructure is complete at its output boundary and what - later stages are not yet included. Pair every stage with its future - verification target, but leave the executable verification design to Role - 3. -3. Write `BUILD_PLAN.md` with the stage output boundaries, datum, intended - construction strategy, and the requirement-to-stage mapping. Role 3 adds - verification contracts and Role 4 adds modeling hypotheses and source - details while the loop runs. -4. Initialize the TODO tool with phases `Requirements / Plan / Build & Verify / - Export`. For each stage create stable `plan verifier`, `model`, and `run - verifier` items. The `run verifier` item is completed only after Role 3 has - executed the prepared check against the Role 4 result. Mark Requirements - done, Plan done, and start the first stage's `plan verifier` item. - -Present the plan in ≤10 lines. No approval gate; enter the Role 3/4 loop. - - - -### Host binding: TODO tool (omp) - -Tool name: `todo`. Operations: `init(list=[{phase, items[]}])`, -`start(task)`, `done(task|phase)`, `drop(task|phase)`, `block(task, reason)`, -`unblock(task)`, `append(phase, items[])`, `rm`, `view`. Contract: batch -`todo` calls together with real work in the same turn; task strings are stable -identifiers — quote them exactly when completing. Keep the current stage's -`model` item pending until its `plan verifier` artifact exists. A failed -`run verifier` reopens the owning verifier or model through a `repair:` item; -never advance the stage while either owner is open. - - - - - -### Host binding: TODO tool (opencode) - -Tool name: `todowrite`. There is a single operation: submit the whole list -as `todos=[{content, status, priority}]` with statuses -`pending / in_progress / completed / cancelled`. Every call rewrites the -full list, so start, done, drop, and append are all expressed by including -every current item with its updated status in the next call. Contract: batch -the rewrite with real work in the same turn; `content` strings are the -stable identifiers — quote them exactly when completing (`S1 plan -verifier`, `repair: S1 verifier collision check`); keep exactly one item -`in_progress` at a time; mark an item `completed` only at its actual gate, -never speculatively. There is no `blocked` status: a blocked item stays -`in_progress` and a follow-up item records the blocker. Keep the current -stage's `model` item pending until its `plan verifier` artifact exists. A -failed `run verifier` reopens the owning verifier or model through a -`repair:` item; never advance the stage while either owner is open. - - - - - -### Host binding: TODO tool (Claude Code / ZCode) - -Tool name: `TodoWrite`. There is a single operation: submit the whole list as -`todos=[{content, status, priority}]` with statuses -`pending / in_progress / completed` and priorities `high / medium / low`. -Every call replaces the full list, so start, done, drop, and append are all -expressed by including every current item with its updated status in the next -call. Contract: batch the rewrite with real work in the same turn; `content` -strings are the stable identifiers — quote them exactly when completing -(`S1 plan verifier`, `repair: S1 verifier collision check`); keep exactly one -item `in_progress` at a time; mark an item `completed` only at its actual -gate, never speculatively. There is no `blocked` status: a blocked item stays -`pending` or `in_progress` and a follow-up `repair:` item records the -blocker. Keep the current stage's `model` item pending until its -`plan verifier` artifact exists. A failed `run verifier` reopens the owning -verifier or model through a `repair:` item; never advance the stage while -either owner is open. - - - - - -### Host binding: TODO tool (codex) - -Tool name: `update_plan`. One operation: submit the whole plan as -`plan=[{step, status}]` with statuses `pending / in_progress / completed`. -Every call rewrites the full plan, so start, done, drop, and append are all -expressed by including every current step with its updated status in the next -call. Contract: batch the rewrite with real work in the same turn; `step` -strings are the stable identifiers — quote them exactly (`S1 plan verifier`, -`repair: S1 verifier collision check`); keep exactly one step `in_progress` -at a time; mark a step `completed` only at its actual gate, never -speculatively. There is no `blocked` status: a blocked step stays `pending` -or `in_progress` and a follow-up `repair:` step records the blocker. Keep the -current stage's `model` step pending until its `plan verifier` artifact -exists. A failed `run verifier` reopens the owning verifier or model through -a `repair:` step; never advance the stage while either owner is open. If -`update_plan` is unavailable in the current session, keep the same list as a -checklist section in `BUILD_PLAN.md`, update it in the same turn as the work -it gates, and quote the current checklist state when reporting a gate -decision. - - - - - -### Host binding: TODO tool, conservative fallback (dsh / kimicode) - -No native TODO tool is assumed for this host. Keep the stage checklist as a -fenced checklist section in `BUILD_PLAN.md` and treat the file as the gate -ledger: update it in the same turn as the work it gates; quote the exact item -strings (`S1 plan verifier`, `repair: S1 verifier collision check`); keep -exactly one item marked in-progress; mark an item done only at its actual -gate, never speculatively; record blockers as `repair:` items instead of a -blocked status. Keep the current stage's `model` item pending until its -`plan verifier` artifact exists. A failed `run verifier` reopens the owning -verifier or model through a `repair:` item; never advance the stage while -either owner is open. When claiming a gate decision, quote the current -checklist state. If this host does provide a native TODO tool, map this block -onto it and keep the same gate semantics. - - - -## Role 3 — Verifier Planner - -Role 3 runs before every stage's modeling work and owns the stage's -verification contract. It is the modeling equivalent of writing tests before -implementation. - -For the current `S` stage, in order: - -1. Identify the stage output: name the substructure that must be complete, - its datum/coordinate relationship, and the existing geometry it may touch. - State what is deliberately out of scope until later stages. -2. Translate the requirements into acceptance criteria for that output: - dimensions and tolerances; required faces, holes, axes, or features; - interfaces and fit with already-built geometry; collision and clearance; - fastener insertion and tool access; assembly reachability; demolding or - manufacturing access when relevant; absence of unintended interference; - BREP validity and single-solid expectations. Include visual criteria when -3. Turn every criterion into a deterministic check. Iterate through small - hypothesis code snippets or probes in the available execution environment: - deliberately good and bad probes must show that the proposed measurement, - collision test, topology query, or visual review contract distinguishes pass - from fail. Reject a check that merely prints a plausible value without - testing the requirement. -4. Produce the executable verification script or function for this stage, - including explicit pass/fail assertions, named measurements, and small - evidence prints. Append to `BUILD_PLAN.md`: - `Verifier contract: / Criteria: <...> / Script: ` - plus the hypothesis results and known-bad proof. -5. Update TODO before modeling: complete the current `plan verifier` item - after the contract, executable check, hypothesis evidence, and known-bad - proof are recorded. The `model` item may then start. After Role 4 produces - the stage result, start `run verifier` and execute the exact prepared - function or script against that result; only its passing output closes the - stage. - -Role 3 may prepare visual verification here. It defines named render views, -close-ups, comparison regions, and the structured verdict required from an -isolated reviewer. It does not treat a render it produced as its own visual -acceptance. - - - -### Host binding: persistent REPL and TODO state machine for Verifier Planner (omp) - -Use the persistent `eval` tool (language `py`) for hypothesis snippets and -known-bad probes. Keep setup, hypothesis, and final check cells incremental; -do not hide an unproven check in a temporary one-shot script. Use `todo` to -make the current stage's verification contract a real gate, not a note. - -Role 2 creates the stable stage tasks in the `Build & Verify` phase. For each -stage, the initial task order is: - -```text -S1 plan verifier -S1 model -S1 run verifier -S2 plan verifier -S2 model -S2 run verifier -... -``` - -Use the exact task strings consistently. When Role 3 enters a stage, start its -`plan verifier` task: - -```text -todo(op="start", task="S1 plan verifier") -``` - -If a stage was added after the original initialization, append all three tasks -as one ordered batch before starting the first one: - -```text -todo(op="append", phase="Build & Verify", items=[ - "S3 plan verifier", - "S3 model", - "S3 run verifier", -]) -todo(op="start", task="S3 plan verifier") -``` - -Do not append a later stage's tasks while the current stage is still open. A -repair item for the current stage is the exception: append it next to the -owning task and keep the stage open. - -After the verifier contract, executable check, hypothesis evidence, and -known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and -open the modeling gate in the same work turn: - -```text -todo(op="done", task="S1 plan verifier") -todo(op="start", task="S1 model") -``` - -Role 4 owns the model task. It must not start before the `plan verifier` task -is done. After Role 4's source run completes, Role 4 closes `S1 model` and -starts `S1 run verifier`; Role 3 then executes the exact prepared function or -script named in that task. - -On a passing verification, record the output/evidence and close the gate: - -```text -todo(op="done", task="S1 run verifier") -``` - -Only then may Role 3 start `S2 plan verifier`. On a failed check, do not call -`done`, `drop`, or rewrite the acceptance criterion merely to clear the list. -Identify the owner and append a stable repair task in the same phase, for -example: - -```text -todo(op="append", phase="Build & Verify", items=[ - "repair: S1 verifier collision check", -]) -todo(op="start", task="repair: S1 verifier collision check") -``` - -If the check is wrong, repair and re-prove the verifier contract; if the model -is wrong, keep the verifier task intact and append a model repair item. After -repair, return to the current stage's `run verifier`; never advance to `S2`. -Use `todo(op="view")` after a repair or interruption to re-ground task text -and status. Batch TODO calls with the real REPL, file, or verification work in -the same turn; keep task strings stable and mark each item done immediately at -its actual gate. - - - - - -### Host binding: file-based hypothesis loop and TODO state machine for Verifier Planner (opencode) - -opencode has no persistent REPL. Python runs through the `bash` tool and -every invocation is a fresh process: names do not survive between calls. -Keep each hypothesis snippet, known-bad probe, and final check as a numbered -script file under the part directory (e.g. -`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`) -and run it with `python` via `bash`; persist intermediate state in files -(JSON, pickle, or a captured package) and reload it in the next script. The -script file is the evidence: do not hide an unproven check in a throwaway -one-shot command whose text is lost. Use `todowrite` to make the current -stage's verification contract a real gate, not a note. - -Role 2 creates the stable stage tasks in the `Build & Verify` phase. For -each stage, the initial task order is: - -```text -S1 plan verifier -S1 model -S1 run verifier -S2 plan verifier -S2 model -S2 run verifier -... -``` - -Use the exact task strings as `content` values in every `todowrite` call. -When Role 3 enters a stage, submit the full list with that item as the only -`in_progress` entry. If a stage is added after the original initialization, -add its three tasks as one ordered batch in the next rewrite before starting -the first one; do not add a later stage's tasks while the current stage is -still open. A repair item for the current stage is the exception: add it -next to the owning task and keep the stage open. - -After the verifier contract, executable check, hypothesis evidence, and -known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and -open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, -`S1 model` -> `in_progress`. Role 4 owns the model task; it must not be -`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source -run completes, mark `S1 model` completed, set `S1 run verifier` -`in_progress`, and execute the exact prepared script named in that task. - -On a passing verification, record the output/evidence and mark `S1 run -verifier` completed; only then may `S2 plan verifier` become `in_progress`. -On a failed check, do not flip statuses merely to clear the list: identify -the owner, add a stable `repair:` item (e.g. `repair: S1 verifier collision -check`), make it the `in_progress` item, and after the repair return to the -current stage's `run verifier`; never advance to `S2`. The `todowrite` -result echoes the current list — use it to re-ground task strings and status -after a repair or an interruption. Batch each rewrite with the real bash, -file, or verification work in the same turn. - - - - - -### Host binding: file-based hypothesis loop and TODO state machine for Verifier Planner (Claude Code / ZCode) - -This host has no persistent REPL. Python runs through the `Bash` tool and -every invocation is a fresh process: names do not survive between calls. -Keep each hypothesis snippet, known-bad probe, and final check as a numbered -script file under the part directory (e.g. -`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`) and -run it with `python` via `Bash`; persist intermediate state in files (JSON, -pickle, or a captured package) and reload it in the next script. The script -file is the evidence: do not hide an unproven check in a throwaway one-shot -command whose text is lost. Use `TodoWrite` to make the current stage's -verification contract a real gate, not a note. - -Role 2 creates the stable stage tasks in the `Build & Verify` phase. For each -stage, the initial task order is: - -```text -S1 plan verifier -S1 model -S1 run verifier -S2 plan verifier -S2 model -S2 run verifier -... -``` - -Use the exact task strings as `content` values in every `TodoWrite` call. -When Role 3 enters a stage, submit the full list with that item as the only -`in_progress` entry. If a stage is added after the original initialization, -add its three tasks as one ordered batch in the next rewrite before starting -the first one; do not add a later stage's tasks while the current stage is -still open. A `repair:` item for the current stage is the exception: add it -next to the owning task and keep the stage open. - -After the verifier contract, executable check, hypothesis evidence, and -known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and -open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, -`S1 model` -> `in_progress`. Role 4 owns the model task; it must not be -`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source -run completes, mark `S1 model` completed, set `S1 run verifier` -`in_progress`, and execute the exact prepared script named in that task. - -On a passing verification, record the output/evidence and mark `S1 run -verifier` completed; only then may `S2 plan verifier` become `in_progress`. -On a failed check, do not flip statuses merely to clear the list: identify -the owner, add a stable `repair:` item (e.g. `repair: S1 verifier collision -check`), make it the `in_progress` item, and after the repair return to the -current stage's `run verifier`; never advance to `S2`. The `TodoWrite` result -echoes the current list — use it to re-ground task strings and status after a -repair or an interruption. Batch each rewrite with the real `Bash`, file, or -verification work in the same turn. - - - - - -### Host binding: file-based hypothesis loop and plan state machine for Verifier Planner (codex) - -codex has no persistent REPL. Python runs through the shell tool and every -invocation is a fresh process: names do not survive between calls. Keep each -hypothesis snippet, known-bad probe, and final check as a numbered script -file under the part directory (e.g. `/verify/s1_hypothesis.py`, -`s1_known_bad.py`, `s1_verify.py`), run it with `python` through the shell, -and persist intermediate state in files (JSON, pickle, or a captured package) -reloaded by the next script. The script file is the evidence: do not hide an -unproven check in a throwaway one-shot command whose text is lost. Use -`update_plan` to make the current stage's verification contract a real gate, -not a note. - -Role 2 creates the stable stage steps in the `Build & Verify` phase in the -order `S1 plan verifier`, `S1 model`, `S1 run verifier`, then `S2 ...`, and -so on. Use the exact step strings in every `update_plan` call. When Role 3 -enters a stage, submit the full plan with that step as the only `in_progress` -entry. If a stage is added after the original initialization, add its three -steps as one ordered batch in the next rewrite before starting the first one; -do not add a later stage's steps while the current stage is still open. A -`repair:` step for the current stage is the exception: add it next to the -owning step and keep the stage open. - -After the verifier contract, executable check, hypothesis evidence, and -known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and -open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, -`S1 model` -> `in_progress`. Role 4 owns the model step; it must not be -`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source -run completes, mark `S1 model` completed, set `S1 run verifier` -`in_progress`, and execute the exact prepared script named in that step. - -On a passing verification, record the output/evidence and mark `S1 run -verifier` completed; only then may `S2 plan verifier` become `in_progress`. -On a failed check, do not flip statuses merely to clear the list: identify -the owner, add a stable `repair:` step, make it the `in_progress` step, and -after the repair return to the current stage's `run verifier`; never advance -to `S2`. Batch each rewrite with the real shell, file, or verification work -in the same turn. - - - - - -### Host binding: file-based hypothesis loop and checklist state machine for Verifier Planner (dsh / kimicode) - -No persistent REPL or native TODO tool is assumed for this host. Python runs -through the shell and every invocation is a fresh process: names do not -survive between calls. Keep each hypothesis snippet, known-bad probe, and -final check as a numbered script file under the part directory (e.g. -`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`), -run it with `python` through the shell, and persist intermediate state in -files (JSON, pickle, or a captured package) reloaded by the next script. The -script file is the evidence: do not hide an unproven check in a throwaway -one-shot command whose text is lost. - -Keep the stage checklist (`S1 plan verifier`, `S1 model`, `S1 run verifier`, -then `S2 ...`) as a fenced checklist section in `BUILD_PLAN.md` and treat it -as the gate ledger: when Role 3 enters a stage, mark that stage's -`plan verifier` item in-progress in the same turn; when the verifier -contract, executable check, hypothesis evidence, and known-bad proof are -recorded, mark `S1 plan verifier` done and `S1 model` in-progress in one -edit. Role 4 owns the model item and must not start while `S1 plan verifier` -is unfinished. After Role 4's source run completes, mark `S1 model` done, set -`S1 run verifier` in-progress, and execute the exact prepared script named in -that item. On a passing verification, record the output/evidence and mark -`S1 run verifier` done; only then may `S2 plan verifier` start. On a failed -check, do not edit statuses merely to clear the list: identify the owner, add -a stable `repair:` item next to the owning item, and after the repair return -to the current stage's `run verifier`; never advance to `S2`. Quote the -current checklist state when reporting a gate decision. Batch each checklist -edit with the real shell, file, or verification work in the same turn. - - - -## Role 4 — Detail Modeling Planner & Builder - -Role 4 starts only after Role 3's current-stage verifier contract and -executable check exist. It owns the detailed construction of the current -stage, not the acceptance definition. - -For the current stage: - -1. Read the verifier contract, then choose a construction strategy from the - geometry and the controlling dimensions. The categories below are a design - ladder, **not an API whitelist**: select the operations that express the - current stage's design intent, then read the exact documentation page for - every operation before its first call. A method absent from its page does - not exist. - - - **Profile and sketch first when they control the shape.** Analyze the - required 2D sections, symmetry, datum planes, and dimensional relations. - Use `make_sketch_rsketch` with the applicable `constrain_*` APIs and - `inspect_sketch_rsketchresult` when a constrained sketch carries design - intent; promote it through `make_wire_from_sketch_rwire` or - `make_face_from_sketch_rface`. Build solids from that intent with - `extrude_rsolid`, `revolve_rsolid`, `loft_rsolid`, `sweep_rsolid`, or a - helical sweep when the feature is genuinely helical. A closed profile is - preferred when its parameters directly represent the requirement. - - **Primitives are an alternative for genuinely regular base forms.** Use a - box, cylinder, cone, sphere, or other documented primitive when it maps - directly to the controlled form; do not force a sketch only because one - exists. Keep the controlling dimensions as named parameters either way. - - **Build named feature solids, then combine them deliberately.** Start - from a named base solid, create each additive boss/rib/flange or - subtractive bore/pocket/opening as a separately named solid or tool, then - use `union_rsolid`, `cut_rsolid`, or `intersect_rsolid` to obtain the next - single Solid. Repeat construct-feature -> Boolean -> verify for each - required feature group; ensure intentional positive overlap for unions - and tool overshoot for cuts. Do not assemble an opaque final shape in one - expression. - - **Use advanced features only after the prototype feature is proven.** - Validate one representative feature before `linear_pattern_rsolidlist` or - `radial_pattern_rsolidlist`; use documented transforms or mirrors for - symmetry. Apply `fillet_rsolid`, `chamfer_rsolid`, `shell_rsolid`, and - similar topology-sensitive features after their supporting geometry and - selectors are stable. - - **Use surface construction when the requirement is surface-led.** - Consider documented ruled, patch, Bezier, Gordon, cylindrical, trimmed, - sewn, or lofted surfaces; turn a closed, valid shell into a Solid only - when that is the required topology. Do not approximate a freeform or - transition surface with arbitrary primitives merely because booleans are - familiar. - -2. Name every meaningful construction step before it is used: source variables, - helper functions, exposed parameters, and tags must identify the part role - or feature rather than its creation order. Prefer names such as - `mounting_base`, `port_bore_tool`, `front_flange`, and - `feature.mounting_bore` over `shape2`, `cut1`, or numeric topology indexes. - Attach semantic tags with `apply_tag(shape=..., tag=...)`, inspect them with - `list_tags(shape=...)`, and use tag-aware QL selection where later features - or verification need stable intent. Use `role.*`, `feature.*`, and `tool.*` - namespaces; keep numeric facts in metadata rather than tags. - -3. Use small hypothesis code snippets or probes in the available execution - environment to test the proposed construction sequence on minimal solids. - Print types, counts, volumes, bounds, tag/selection cards, and other facts - required by the verifier contract. Adopt an operation only when its evidence - shows that it can produce the required shape. Append - `Hypothesis: ... / Result: ... / Adopted: ...` under the current stage in - `BUILD_PLAN.md`. -4. Edit the part source only after the operation hypotheses pass. Use keyword - arguments, named parameters, role/feature/tool tags, and QL fact prints; - include selection cards before every fillet/chamfer. -5. Run the source and hand the resulting stage geometry to Role 3. Role 3 - executes the exact prepared verification script. The stage passes only when - all assertions, measurements, fit/access checks, and required visual - verdicts pass. A visual verdict comes from the isolated reviewer contract - planned by Role 3. -5. On failure, do not weaken the check. Determine whether the verifier - contract or the model owns the defect, add a `repair:` TODO to that owner, - and repeat Role 3/4 for the same stage. Start the next stage only after the - current stage's `model` and `run verifier` items are both complete. - - - -### Host binding: persistent REPL for Detail Modeling Planner & Builder (omp) - -Use `eval` (language `py`) for incremental construction hypotheses; names -survive across calls. Keep the real source run in bash. Never use a modeling -success message as the verification result: invoke the prepared verifier -function or script and record its output in the stage evidence. - - - - - -### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (opencode) - -opencode has no persistent REPL; run incremental construction hypotheses as -numbered scripts under the part directory (e.g. -`/hypotheses/s1_base_probe.py`) through `bash`. Names do not -survive between runs: carry intermediate geometry between probes by writing -it to a file (pickle, JSON, or `capture`) and reloading, or by -re-materializing from the part source. Keep the real source run in `bash` -too. Never use a modeling success message as the verification result: -invoke the prepared verifier script and record its output in the stage -evidence. - - - - - -### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (Claude Code / ZCode) - -This host has no persistent REPL; run incremental construction hypotheses as -numbered scripts under the part directory (e.g. -`/hypotheses/s1_base_probe.py`) through the `Bash` tool. Names do -not survive between runs: carry intermediate geometry between probes by -writing it to a file (pickle, JSON, or a captured package) and reloading, or -by re-materializing from the part source. Keep the real source run in `Bash` -too. Never use a modeling success message as the verification result: invoke -the prepared verifier script and record its output in the stage evidence. - - - - - -### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (codex) - -codex has no persistent REPL; run incremental construction hypotheses as -numbered scripts under the part directory (e.g. -`/hypotheses/s1_base_probe.py`) through the shell tool. Names do -not survive between runs: carry intermediate geometry between probes by -writing it to a file (pickle, JSON, or a captured package) and reloading, or -by re-materializing from the part source. Keep the real source run in the -shell too. Never use a modeling success message as the verification result: -invoke the prepared verifier script and record its output in the stage -evidence. - - - - - -### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (dsh / kimicode) - -No persistent REPL is assumed for this host; run incremental construction -hypotheses as numbered scripts under the part directory (e.g. -`/hypotheses/s1_base_probe.py`) through the shell. Names do not -survive between runs: carry intermediate geometry between probes by writing -it to a file (pickle, JSON, or a captured package) and reloading, or by -re-materializing from the part source. Keep the real source run in the shell -too. Never use a modeling success message as the verification result: invoke -the prepared verifier script and record its output in the stage evidence. - - - -## Verifier contract patterns - -Role 3 selects the applicable checks per stage; it does not mechanically run -every pattern. A contract may combine: - -- dimensional measurements with explicit tolerance bounds; -- topology, axis, and face-location queries for required features; -- BREP closure, positive volume, and solid-count checks; -- swept insertion envelopes for screws, tools, or assembly access, with a - zero-collision criterion; -- clearance and interference checks against already-built substructures; -- demolding or tool-direction access checks when manufacturing intent requires; -- named renders and isolated per-region visual verdicts; -- functional motion or clearance checks when the part has moving interfaces; -- FEM only when service conditions are stated in `REQUIREMENTS.md`. - -Every selected pattern needs a deterministic pass/fail condition and a -known-bad hypothesis proof before the current stage is modeled. - - - -### Host binding: mandatory isolated image review (omp) - -When Role 3 selects visual verification, any image-based acceptance judgment -must be executed by a clean-context reviewer. The context that produced a -render cannot certify it. Role 3 defines the visual contract before Role 4 -builds: reference/render paths, named views, regions, required verdict fields, -and the reviewer input description. - -- Default: spawn one isolated `task` reviewer with file paths and the written - shape description from `REQUIREMENTS.md`; demand structured per-region - `match` / `mismatch` / `unreviewable` verdicts with reasons. -- If a standing reviewer peer exists, use `hub` to send the same contract and - record its reply verbatim. -- A mismatch cannot be overridden by the main context. Repair the owning - model, render again, and repeat the isolated review. -- If neither reviewer mechanism exists, the visual gate remains open and the - stage cannot pass. - - - - - -### Host binding: mandatory isolated image review (opencode) - -When Role 3 selects visual verification, any image-based acceptance judgment -must be executed by a subagent with a clean context. The context that -produced a render cannot certify it. Role 3 defines the visual contract -before Role 4 builds: reference/render paths, named views, regions, required -verdict fields, and the reviewer input description. - -- Default and only mechanism: spawn one isolated reviewer with the `task` - tool, `subagent_type: "general"` (fresh context, able to read image - files). Pass the render/reference file paths and the written shape - description from `REQUIREMENTS.md`; require structured per-region `match` - / `mismatch` / `unreviewable` verdicts with reasons in the reviewer's - final report, and quote that report verbatim in the stage evidence. Spawn - a fresh reviewer for every re-review; do not resume a previous reviewer - session (task-id reuse would carry the context of the earlier render it - already judged). -- opencode has no standing-reviewer-peer mechanism; the hub option cannot - be mapped. If the `task` tool is unavailable, the visual gate remains - open and the stage cannot pass. -- A mismatch cannot be overridden by the main context. Repair the owning - model, render again, and repeat the isolated review. - - - - - -### Host binding: mandatory isolated image review (Claude Code / ZCode) - -When Role 3 selects visual verification, any image-based acceptance judgment -must be executed by a subagent with a clean context. The context that -produced a render cannot certify it. Role 3 defines the visual contract -before Role 4 builds: reference/render paths, named views, regions, required -verdict fields, and the reviewer input description. - -- Default and only mechanism: spawn one isolated reviewer subagent (ZCode - `Agent` tool / Claude Code `Task` tool, `subagent_type: "general-purpose"` - — fresh context, able to read image files). Pass the render/reference file - paths and the written shape description from `REQUIREMENTS.md`; require - structured per-region `match` / `mismatch` / `unreviewable` verdicts with - reasons in the reviewer's final report, and quote that report verbatim in - the stage evidence. The subagent's final message is the review record. -- Spawn a fresh reviewer for every re-review; do not resume a previous - reviewer session or reuse a task id (its context carries the earlier - render it already judged, so it can no longer certify a new one). -- This host has no standing-reviewer-peer mechanism; the hub option cannot - be mapped. If the subagent spawn tool is unavailable, the visual gate - remains open and the stage cannot pass. -- A mismatch cannot be overridden by the main context. Repair the owning - model, render again, and repeat the isolated review. - - - - - -### Host binding: mandatory isolated image review (codex) - -When Role 3 selects visual verification, any image-based acceptance judgment -must come from a context that did not produce the render; the modeling -context cannot certify it. Role 3 defines the visual contract before Role 4 -builds: reference/render paths, named views, regions, required verdict -fields, and the reviewer input description. - -- codex has no subagent spawn mechanism. The fallback reviewer is the user: - present the render together with the written visual contract (views, - regions, and the verdict fields you need) in one message, then stop and - wait. Record the user's verdict verbatim in the stage evidence as the - review record; a partial verdict is `unreviewable` for the uncovered - regions. -- If the user cannot review in this round, the visual gate remains open and - the stage cannot pass; do not substitute the modeling context's own - judgment for the review. -- A mismatch cannot be overridden by the modeling context. Repair the owning - model, render again, and repeat the review. - - - - - -### Host binding: mandatory isolated image review, conservative fallback (dsh / kimicode) - -When Role 3 selects visual verification, any image-based acceptance judgment -must come from a context that did not produce the render; the modeling -context cannot certify it. Role 3 defines the visual contract before Role 4 -builds: reference/render paths, named views, regions, required verdict -fields, and the reviewer input description. - -- No subagent mechanism is assumed for this host. If the host provides one, - spawn an isolated reviewer subagent with fresh context per re-review, pass - the render/reference paths and the written shape description, require - structured per-region `match` / `mismatch` / `unreviewable` verdicts with - reasons, and quote its report verbatim in the stage evidence. -- Without a subagent mechanism, the fallback reviewer is the user: present - the render together with the written visual contract in one message, then - stop and wait; record the user's verdict verbatim as the review record. If - the user cannot review in this round, the visual gate remains open and the - stage cannot pass. -- A mismatch cannot be overridden by the modeling context. Repair the owning - model, render again, and repeat the review. - - - ## Role 5 — Exporter Read the export rows from `REQUIREMENTS.md`. Durable canonical delivery is @@ -1137,6 +144,8 @@ Final delivery ⛔: evidence pack + artifact list + assumptions made + checks not run, and record the user's verdict. A rejection here routes back through the role map to the owning artifact. +**Unattended:** the final delivery is the report, not a question; see the Unattended mode table above. + --- ## Failure routes diff --git a/docs/skill/references/workflows/single-part-modeling/requirements-template.md b/docs/skill/references/workflows/single-part-modeling/requirements-template.md new file mode 100644 index 00000000..7e85a424 --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/requirements-template.md @@ -0,0 +1,24 @@ +# Single-Part Modeling: REQUIREMENTS.md template + +The file Role 1 writes (`workflows/single-part-modeling/role-1-requirements.md` step 4). + +```markdown +# Requirements: +- Mode: +- Inputs: +- Functional goals: +- Geometry targets: +- Units / coordinate convention: +- Named parameters (Var): +- Fastening & mounting: + Envelope math: min land / boss / offset> +- Service conditions: +- Export targets: .scadpkg always; STEP/STL/OBJ/MJCF/FCStd if requested +- Verification intent: +- Ask-or-Record ledger: | item | asked/assumed | answer/value | +- Assumption ledger (unattended; interactive runs use the Ask-or-Record ledger above): + | item | value | evidence | how to change it | the check that would show it wrong | +``` + +The first line of the file states the mode (`discipline/operating-modes.md`). In unattended mode every field is filled; +a recorded assumption fills a field, an empty field does not. diff --git a/docs/skill/references/workflows/single-part-modeling/role-1-ask-bindings.md b/docs/skill/references/workflows/single-part-modeling/role-1-ask-bindings.md new file mode 100644 index 00000000..d842d553 --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-1-ask-bindings.md @@ -0,0 +1,262 @@ +# Single-Part Modeling: Role 1 ask bindings + +**Read when:** Role 1 (interactive mode) is about to send its batched ask round. + +**Unattended:** skip this page. Nothing is asked; every item that would be asked is recorded in the assumption ledger of +`REQUIREMENTS.md` (`value / evidence / how to change it / the check that would show it wrong`). + + + +### Host binding: designing the Role 1 `ask` payload (omp) + +OMP already provides and executes the `ask` tool. This block defines only how +to design its input for the Requirement Confirmer. After writing the +region-by-region interpretation and the Ask-or-Record ledger, make one batched +call for all unresolved items that can block `REQUIREMENTS.md`. + +Design each question so its answer can be copied into one ledger row: + +- Ask about one decision, not one vague topic. Name the affected region, + feature, axis, or interface and state the evidence or ambiguity. +- Ask shape-level interpretation explicitly. Do not hide a disputed region in + a general "does this look right?" question. +- Separate independent decisions such as overall orientation, hole axis, + thread representation, and mounting intent. Use `multi: true` only when the + user may independently select several items from the same set. +- Make options mutually exclusive and operational: each option must say what + will be recorded and what modeling consequence follows. Do not offer + imaginary precision that the source does not support. +- Put the conservative, clearly labeled assumption first and set + `recommended: 0`; this is a recommendation, never consent. Include a + concrete correction path in the options so the user can reject or amend the + interpretation. +- Ask only blocking decisions in this round. Record inferable dimensions and + low-risk defaults as assumptions with their basis; do not burden the user + with API or verification choices owned by later roles. + +Example payload shape (the actual labels and questions must be derived from the +current part, not copied literally): + +```ts +ask({ + questions: [ + { + id: "region_c_flange_location", + question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", + options: [ + { label: "The -Y horizontal-port end", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, + { label: "The top vertical-bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } + ], + recommended: 0 + }, + { + id: "thread_representation", + question: "The drawing calls out M36-6H, but does not establish whether thread faces are needed for this deliverable. How should it be represented?", + options: [ + { label: "Nominal bore", description: "Record the callout and model the nominal bore; do not create helical faces." }, + { label: "Explicit thread geometry", description: "Record modeled thread geometry as required and plan a thread-specific verification." } + ], + recommended: 0 + }, + { + id: "mounting_intent", + question: "What mounting intent should the base holes represent?", + options: [ + { label: "Through-bolted to a flat base", description: "Record insertion along Z and verify the fastener envelope and underside clearance." }, + { label: "Tapped or blind mounting", description: "Record the hole termination and thread requirement; do not assume through holes." } + ], + recommended: 0 + } + ] +}) +``` + +Use stable machine-readable `id` values that match ledger rows. After the call, +copy the returned selected options, custom answers, notes, and any timeout +status into `REQUIREMENTS.md`; preserve the user's wording for corrections. +The gate closes only when every blocking row has an explicit answer or an +explicitly recorded assumption permitted by the requirement discipline. A +recommendation or timeout is not user consent. On cancellation, incomplete +answers, or unavailable interactive UI, keep the gate blocked and stop; never +continue by silently selecting the recommended option. + +When a question has no valid answer among the proposed options, its wording +must make clear what correction or missing requirement the user should supply. +Do not ask the user to choose an implementation detail owned by a later role; +ask only for the requirement that changes the model or its acceptance criteria. + + + + + +### Host binding: designing the Role 1 `question` payload (opencode) + +opencode provides and executes the native `question` tool, which batches all +questions in one call. This block defines only how to design its input for +the Requirement Confirmer. After writing the region-by-region interpretation +and the Ask-or-Record ledger, make one batched call for all unresolved items +that can block `REQUIREMENTS.md`. + +The tool schema differs from a generic ask; design around the differences: + +- There is no `id` field. Key each ledger row to the `header` (max 30 + characters): derive it from the stable identifier you would otherwise use + as an id, e.g. `C-flange location`, `thread representation`. +- There is no `recommended` index. Express the recommendation by putting the + conservative, clearly labeled option first and suffixing its label with + `(Recommended)`; this is a recommendation, never consent. +- A free-text "type your own answer" choice is offered automatically; do not + add your own catch-all option. Answers return as the selected option + labels (arrays when `multiple: true`). Use `multiple: true` only when the + user may independently select several items from the same set. +- Keep each `label` to a few words and put the operational consequence — + what will be recorded and what modeling follows — in `description`. + +The shared design rules still apply: one decision per question, shape-level +interpretation asked explicitly, independent decisions separated into their +own questions, mutually exclusive and operational options, a concrete +correction path in the options, and only blocking decisions in this round. + +Example payload shape (the actual labels and questions must be derived from +the current part, not copied literally): + +```ts +question({ + questions: [ + { + header: "C-flange location", + question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", + options: [ + { label: "-Y horizontal-port end (Recommended)", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, + { label: "Top vertical-bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } + ] + }, + { + header: "Thread representation", + question: "The drawing calls out M36-6H but does not establish whether thread faces are needed for this deliverable. How should it be represented?", + options: [ + { label: "Nominal bore (Recommended)", description: "Record the callout and model the nominal bore; do not create helical faces." }, + { label: "Explicit thread geometry", description: "Record modeled thread geometry as required and plan a thread-specific verification." } + ] + } + ] +}) +``` + +After the call, copy the selected labels, any typed custom answers, and the +questions' headers into `REQUIREMENTS.md` keyed by header; preserve the +user's wording for corrections. A dismissed or skipped question is an +unanswered blocking row: keep the gate blocked and stop; never continue by +silently selecting the recommended option. The gate closes only when every +blocking row has an explicit answer or an explicitly recorded assumption +permitted by the requirement discipline. + +When a question has no valid answer among the proposed options, its wording +must make clear what correction or missing requirement the user should type +as the free-text answer. Do not ask the user to choose an implementation +detail owned by a later role; ask only for the requirement that changes the +model or its acceptance criteria. + + + + + +### Host binding: designing the Role 1 `AskUserQuestion` payload (Claude Code / ZCode) + +The host provides and executes the `AskUserQuestion` tool. This block defines +only how to design its input for the Requirement Confirmer. After writing the +region-by-region interpretation and the Ask-or-Record ledger, make one batched +call for all unresolved items that can block `REQUIREMENTS.md`. + +The schema differs from a generic ask; design around the differences: + +- At most 4 questions per call. If more blocking rows remain, send the + remaining rows as further batched calls in the same turn and say why. +- `header` (max 12 characters, shown as a chip) is the ledger key: derive it + from the stable identifier you would otherwise use as an id, e.g. + `C flange`, `thread`. +- There is no `recommended` index. Put the conservative, clearly labeled + option first and suffix its label with `(Recommended)`; this is a + recommendation, never consent. +- 2–4 options per question; each option is a short `label` plus an + operational `description` saying what will be recorded and what modeling + follows. A free-text "Other" choice is offered automatically; do not add + your own catch-all option. Answers return as the selected labels plus any + typed notes. +- `multiSelect: true` only when the user may independently select several + items from the same set. + +The shared design rules still apply: one decision per question, shape-level +interpretation asked explicitly, independent decisions separated into their +own questions, mutually exclusive and operational options, a concrete +correction path in the options, and only blocking decisions in this round. +Example payload shape (the actual labels and questions must be derived from +the current part, not copied literally): + +```ts +AskUserQuestion({ + questions: [ + { + question: "The drawing leaves the C-view flange location ambiguous. Which region is the C flange?", + header: "C flange", + multiSelect: false, + options: [ + { label: "-Y port end (Recommended)", description: "Record C as the long rounded flange on the -Y end; model its axis along Y." }, + { label: "Top bore end", description: "Record C as the flange around the top vertical bore; revise the region map before modeling." } + ] + } + ] +}) +``` + +After the call, copy the selected labels, any typed custom answers, and notes +into `REQUIREMENTS.md` keyed by header; preserve the user's wording for +corrections. A skipped question or an empty answer is an unanswered blocking +row: keep the gate blocked and stop; never continue by silently selecting the +recommended option. The gate closes only when every blocking row has an +explicit answer or an explicitly recorded assumption permitted by the +requirement discipline. + + + + + +### Host binding: Role 1 blocking questions without a native ask tool (codex) + +codex provides no structured interactive-question tool. Keep the ledger +discipline in plain conversation: after writing the region-by-region +interpretation and the Ask-or-Record ledger, send ONE message listing every +unresolved item that can block `REQUIREMENTS.md` — one numbered question per +decision, lettered mutually exclusive options each stating the operational +consequence, the conservative interpretation first and marked +`(recommended)`, and an explicit invitation to correct or type a different +answer. Then stop: make no further tool call until the user replies. Record +the reply verbatim in `REQUIREMENTS.md` under the same ledger keys, preserving +the user's wording for corrections. A partial answer or a general "sounds +good" does not answer the open rows: re-ask exactly those rows and keep the +gate blocked; never continue by silently selecting the recommended option. +The gate closes only when every blocking row has an explicit answer or an +explicitly recorded assumption permitted by the requirement discipline. + + + + + +### Host binding: Role 1 blocking questions, conservative fallback (dsh / kimicode) + +No native interactive-question tool is assumed for this host, so use the +plain-text ledger discipline: after writing the region-by-region +interpretation and the Ask-or-Record ledger, send ONE message listing every +unresolved item that can block `REQUIREMENTS.md` — one numbered question per +decision, lettered mutually exclusive options each stating the operational +consequence, the conservative interpretation first and marked +`(recommended)`, and an explicit invitation to correct or type a different +answer. Then stop and wait for the user's reply; record it verbatim in +`REQUIREMENTS.md` under the same ledger keys. A partial answer does not +answer the open rows: re-ask them and keep the gate blocked; never continue +by silently selecting the recommended option. If this host does provide a +native ask/question tool, map this block onto it and keep the same gate +semantics: one batched round, blocking decisions only, and a recommendation +that is never consent. + + diff --git a/docs/skill/references/workflows/single-part-modeling/role-1-requirements.md b/docs/skill/references/workflows/single-part-modeling/role-1-requirements.md new file mode 100644 index 00000000..d8653a2b --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-1-requirements.md @@ -0,0 +1,49 @@ +# Single-Part Modeling: Role 1, Requirement Confirmer + +Outline, role map and standing discipline: `workflows/single-part-modeling.md`. + +## Role 1 — Requirement Confirmer ⛔ + +Convert the request into `REQUIREMENTS.md`. No geometry, no probe builds, +no source-file writes before this role's gate closes. + +Procedure: + +1. Classify inputs (prose / reference images / drawings / mesh models ,etc.). For any image + containing reference structure: write the interpreted shape region by + region — overall form, visible features and locations, symmetry and + orientation, anything hidden or uncertain. **Describing is not + confirming:** the interpretation ends as part of ONE batched ask round + that also covers the Ask-or-Record blocking table + (`domains/requirement-refinement.md`). Shape-level facts are always + confirmed; only dimension-level defaults may be assumed once a scale + anchor exists. + For provided models(most cases are mesh), use python code to render and inspect geometry information through something like sampling / fitting, which may provide useful geometry information for you to build similar stucture / rebuild same struction in brep through simplecadapi. +2. For undetermined parameters / geometry / function / performance, propose + recommended assumptions as ask options (default first) so the user can + accept or override in one round. + + +**Unattended:** the gate is a self-review gate, and Ask-or-Record items are recorded in the assumption ledger, not asked +(`discipline/operating-modes.md`). Shape-level facts are still described region by region in `REQUIREMENTS.md`; the +interpretation is checked against the evidence (measured mesh / drawing facts first) and its confidence is recorded. It is +never skipped. + +**Interactive host bindings** for the batched ask round (the `ask` / `question` / `AskUserQuestion` payload design, or the +plain-text fallback) are in `workflows/single-part-modeling/role-1-ask-bindings.md`; read it when you are about to ask. + +3. Fastened or load-bearing parts: the requirements contain the envelope + arithmetic, not just values — head diameter + shaft + wall/clearance → + minimum land diameter, boss thickness, edge distance, and lateral + offset along the insertion path. +4. Write `REQUIREMENTS.md`: +(the template is in `workflows/single-part-modeling/requirements-template.md`; fill every field, record an assumption for any field not answered) + +Gate: the ask round returned answers, consent is recorded in the ledger, and +`REQUIREMENTS.md` exists. Entering Role 2 without this gate is the cardinal +violation. + +**Unattended:** the gate passes when `REQUIREMENTS.md` exists with `Mode: unattended`, every field filled (an assumption +recorded in the ledger fills a field; an empty field does not), and one paragraph of decision with the open risks written +under the ledger. The self-review is a re-read of the brief against the request and the evidence. Entering Role 2 without +this artifact is still the cardinal violation. diff --git a/docs/skill/references/workflows/single-part-modeling/role-2-plan.md b/docs/skill/references/workflows/single-part-modeling/role-2-plan.md new file mode 100644 index 00000000..574e7dd9 --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-2-plan.md @@ -0,0 +1,132 @@ +# Single-Part Modeling: Role 2, Master Planner + +Outline: `workflows/single-part-modeling.md`. + +## Role 2 — Master Planner + +From `REQUIREMENTS.md` only — no API detail, no step-level modeling. + +1. Choose the construction strategy (profile-driven vs block-and-feature) + and fix the datum from the functional interface; one-paragraph rationale + each. +2. Design the construction as numbered stages `S1..Sn`. For each stage state + exactly what new substructure is complete at its output boundary and what + later stages are not yet included. Pair every stage with its future + verification target, but leave the executable verification design to Role + 3. +3. Write `BUILD_PLAN.md` with the stage output boundaries, datum, intended + construction strategy, and the requirement-to-stage mapping. Role 3 adds + verification contracts and Role 4 adds modeling hypotheses and source + details while the loop runs. +4. Initialize the TODO tool with phases `Requirements / Plan / Build & Verify / + Export`. For each stage create stable `plan verifier`, `model`, and `run + verifier` items. The `run verifier` item is completed only after Role 3 has + executed the prepared check against the Role 4 result. Mark Requirements + done, Plan done, and start the first stage's `plan verifier` item. + +Present the plan in ≤10 lines. No approval gate; enter the Role 3/4 loop. + +**Unattended:** the plan is not presented to anyone; it is the file `BUILD_PLAN.md` and a short summary in the report +(`discipline/operating-modes.md`). Where the plan has two viable construction strategies, take the one that meets the +stated requirements and is easier to change later, and record the other in `BUILD_PLAN.md`. + + + +### Host binding: TODO tool (omp) + +Tool name: `todo`. Operations: `init(list=[{phase, items[]}])`, +`start(task)`, `done(task|phase)`, `drop(task|phase)`, `block(task, reason)`, +`unblock(task)`, `append(phase, items[])`, `rm`, `view`. Contract: batch +`todo` calls together with real work in the same turn; task strings are stable +identifiers — quote them exactly when completing. Keep the current stage's +`model` item pending until its `plan verifier` artifact exists. A failed +`run verifier` reopens the owning verifier or model through a `repair:` item; +never advance the stage while either owner is open. + + + + + +### Host binding: TODO tool (opencode) + +Tool name: `todowrite`. There is a single operation: submit the whole list +as `todos=[{content, status, priority}]` with statuses +`pending / in_progress / completed / cancelled`. Every call rewrites the +full list, so start, done, drop, and append are all expressed by including +every current item with its updated status in the next call. Contract: batch +the rewrite with real work in the same turn; `content` strings are the +stable identifiers — quote them exactly when completing (`S1 plan +verifier`, `repair: S1 verifier collision check`); keep exactly one item +`in_progress` at a time; mark an item `completed` only at its actual gate, +never speculatively. There is no `blocked` status: a blocked item stays +`in_progress` and a follow-up item records the blocker. Keep the current +stage's `model` item pending until its `plan verifier` artifact exists. A +failed `run verifier` reopens the owning verifier or model through a +`repair:` item; never advance the stage while either owner is open. + + + + + +### Host binding: TODO tool (Claude Code / ZCode) + +Tool name: `TodoWrite`. There is a single operation: submit the whole list as +`todos=[{content, status, priority}]` with statuses +`pending / in_progress / completed` and priorities `high / medium / low`. +Every call replaces the full list, so start, done, drop, and append are all +expressed by including every current item with its updated status in the next +call. Contract: batch the rewrite with real work in the same turn; `content` +strings are the stable identifiers — quote them exactly when completing +(`S1 plan verifier`, `repair: S1 verifier collision check`); keep exactly one +item `in_progress` at a time; mark an item `completed` only at its actual +gate, never speculatively. There is no `blocked` status: a blocked item stays +`pending` or `in_progress` and a follow-up `repair:` item records the +blocker. Keep the current stage's `model` item pending until its +`plan verifier` artifact exists. A failed `run verifier` reopens the owning +verifier or model through a `repair:` item; never advance the stage while +either owner is open. + + + + + +### Host binding: TODO tool (codex) + +Tool name: `update_plan`. One operation: submit the whole plan as +`plan=[{step, status}]` with statuses `pending / in_progress / completed`. +Every call rewrites the full plan, so start, done, drop, and append are all +expressed by including every current step with its updated status in the next +call. Contract: batch the rewrite with real work in the same turn; `step` +strings are the stable identifiers — quote them exactly (`S1 plan verifier`, +`repair: S1 verifier collision check`); keep exactly one step `in_progress` +at a time; mark a step `completed` only at its actual gate, never +speculatively. There is no `blocked` status: a blocked step stays `pending` +or `in_progress` and a follow-up `repair:` step records the blocker. Keep the +current stage's `model` step pending until its `plan verifier` artifact +exists. A failed `run verifier` reopens the owning verifier or model through +a `repair:` step; never advance the stage while either owner is open. If +`update_plan` is unavailable in the current session, keep the same list as a +checklist section in `BUILD_PLAN.md`, update it in the same turn as the work +it gates, and quote the current checklist state when reporting a gate +decision. + + + + + +### Host binding: TODO tool, conservative fallback (dsh / kimicode) + +No native TODO tool is assumed for this host. Keep the stage checklist as a +fenced checklist section in `BUILD_PLAN.md` and treat the file as the gate +ledger: update it in the same turn as the work it gates; quote the exact item +strings (`S1 plan verifier`, `repair: S1 verifier collision check`); keep +exactly one item marked in-progress; mark an item done only at its actual +gate, never speculatively; record blockers as `repair:` items instead of a +blocked status. Keep the current stage's `model` item pending until its +`plan verifier` artifact exists. A failed `run verifier` reopens the owning +verifier or model through a `repair:` item; never advance the stage while +either owner is open. When claiming a gate decision, quote the current +checklist state. If this host does provide a native TODO tool, map this block +onto it and keep the same gate semantics. + + diff --git a/docs/skill/references/workflows/single-part-modeling/role-3-host-bindings.md b/docs/skill/references/workflows/single-part-modeling/role-3-host-bindings.md new file mode 100644 index 00000000..7ef1c7af --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-3-host-bindings.md @@ -0,0 +1,278 @@ +# Single-Part Modeling: Role 3 host bindings + +**Read when:** Role 3 starts a stage (the hypothesis loop and the TODO state machine for the Verifier Planner). + + + +### Host binding: persistent REPL and TODO state machine for Verifier Planner (omp) + +Use the persistent `eval` tool (language `py`) for hypothesis snippets and +known-bad probes. Keep setup, hypothesis, and final check cells incremental; +do not hide an unproven check in a temporary one-shot script. Use `todo` to +make the current stage's verification contract a real gate, not a note. + +Role 2 creates the stable stage tasks in the `Build & Verify` phase. For each +stage, the initial task order is: + +```text +S1 plan verifier +S1 model +S1 run verifier +S2 plan verifier +S2 model +S2 run verifier +... +``` + +Use the exact task strings consistently. When Role 3 enters a stage, start its +`plan verifier` task: + +```text +todo(op="start", task="S1 plan verifier") +``` + +If a stage was added after the original initialization, append all three tasks +as one ordered batch before starting the first one: + +```text +todo(op="append", phase="Build & Verify", items=[ + "S3 plan verifier", + "S3 model", + "S3 run verifier", +]) +todo(op="start", task="S3 plan verifier") +``` + +Do not append a later stage's tasks while the current stage is still open. A +repair item for the current stage is the exception: append it next to the +owning task and keep the stage open. + +After the verifier contract, executable check, hypothesis evidence, and +known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and +open the modeling gate in the same work turn: + +```text +todo(op="done", task="S1 plan verifier") +todo(op="start", task="S1 model") +``` + +Role 4 owns the model task. It must not start before the `plan verifier` task +is done. After Role 4's source run completes, Role 4 closes `S1 model` and +starts `S1 run verifier`; Role 3 then executes the exact prepared function or +script named in that task. + +On a passing verification, record the output/evidence and close the gate: + +```text +todo(op="done", task="S1 run verifier") +``` + +Only then may Role 3 start `S2 plan verifier`. On a failed check, do not call +`done`, `drop`, or rewrite the acceptance criterion merely to clear the list. +Identify the owner and append a stable repair task in the same phase, for +example: + +```text +todo(op="append", phase="Build & Verify", items=[ + "repair: S1 verifier collision check", +]) +todo(op="start", task="repair: S1 verifier collision check") +``` + +If the check is wrong, repair and re-prove the verifier contract; if the model +is wrong, keep the verifier task intact and append a model repair item. After +repair, return to the current stage's `run verifier`; never advance to `S2`. +Use `todo(op="view")` after a repair or interruption to re-ground task text +and status. Batch TODO calls with the real REPL, file, or verification work in +the same turn; keep task strings stable and mark each item done immediately at +its actual gate. + + + + + +### Host binding: file-based hypothesis loop and TODO state machine for Verifier Planner (opencode) + +opencode has no persistent REPL. Python runs through the `bash` tool and +every invocation is a fresh process: names do not survive between calls. +Keep each hypothesis snippet, known-bad probe, and final check as a numbered +script file under the part directory (e.g. +`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`) +and run it with `python` via `bash`; persist intermediate state in files +(JSON, pickle, or a captured package) and reload it in the next script. The +script file is the evidence: do not hide an unproven check in a throwaway +one-shot command whose text is lost. Use `todowrite` to make the current +stage's verification contract a real gate, not a note. + +Role 2 creates the stable stage tasks in the `Build & Verify` phase. For +each stage, the initial task order is: + +```text +S1 plan verifier +S1 model +S1 run verifier +S2 plan verifier +S2 model +S2 run verifier +... +``` + +Use the exact task strings as `content` values in every `todowrite` call. +When Role 3 enters a stage, submit the full list with that item as the only +`in_progress` entry. If a stage is added after the original initialization, +add its three tasks as one ordered batch in the next rewrite before starting +the first one; do not add a later stage's tasks while the current stage is +still open. A repair item for the current stage is the exception: add it +next to the owning task and keep the stage open. + +After the verifier contract, executable check, hypothesis evidence, and +known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and +open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, +`S1 model` -> `in_progress`. Role 4 owns the model task; it must not be +`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source +run completes, mark `S1 model` completed, set `S1 run verifier` +`in_progress`, and execute the exact prepared script named in that task. + +On a passing verification, record the output/evidence and mark `S1 run +verifier` completed; only then may `S2 plan verifier` become `in_progress`. +On a failed check, do not flip statuses merely to clear the list: identify +the owner, add a stable `repair:` item (e.g. `repair: S1 verifier collision +check`), make it the `in_progress` item, and after the repair return to the +current stage's `run verifier`; never advance to `S2`. The `todowrite` +result echoes the current list — use it to re-ground task strings and status +after a repair or an interruption. Batch each rewrite with the real bash, +file, or verification work in the same turn. + + + + + +### Host binding: file-based hypothesis loop and TODO state machine for Verifier Planner (Claude Code / ZCode) + +This host has no persistent REPL. Python runs through the `Bash` tool and +every invocation is a fresh process: names do not survive between calls. +Keep each hypothesis snippet, known-bad probe, and final check as a numbered +script file under the part directory (e.g. +`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`) and +run it with `python` via `Bash`; persist intermediate state in files (JSON, +pickle, or a captured package) and reload it in the next script. The script +file is the evidence: do not hide an unproven check in a throwaway one-shot +command whose text is lost. Use `TodoWrite` to make the current stage's +verification contract a real gate, not a note. + +Role 2 creates the stable stage tasks in the `Build & Verify` phase. For each +stage, the initial task order is: + +```text +S1 plan verifier +S1 model +S1 run verifier +S2 plan verifier +S2 model +S2 run verifier +... +``` + +Use the exact task strings as `content` values in every `TodoWrite` call. +When Role 3 enters a stage, submit the full list with that item as the only +`in_progress` entry. If a stage is added after the original initialization, +add its three tasks as one ordered batch in the next rewrite before starting +the first one; do not add a later stage's tasks while the current stage is +still open. A `repair:` item for the current stage is the exception: add it +next to the owning task and keep the stage open. + +After the verifier contract, executable check, hypothesis evidence, and +known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and +open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, +`S1 model` -> `in_progress`. Role 4 owns the model task; it must not be +`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source +run completes, mark `S1 model` completed, set `S1 run verifier` +`in_progress`, and execute the exact prepared script named in that task. + +On a passing verification, record the output/evidence and mark `S1 run +verifier` completed; only then may `S2 plan verifier` become `in_progress`. +On a failed check, do not flip statuses merely to clear the list: identify +the owner, add a stable `repair:` item (e.g. `repair: S1 verifier collision +check`), make it the `in_progress` item, and after the repair return to the +current stage's `run verifier`; never advance to `S2`. The `TodoWrite` result +echoes the current list — use it to re-ground task strings and status after a +repair or an interruption. Batch each rewrite with the real `Bash`, file, or +verification work in the same turn. + + + + + +### Host binding: file-based hypothesis loop and plan state machine for Verifier Planner (codex) + +codex has no persistent REPL. Python runs through the shell tool and every +invocation is a fresh process: names do not survive between calls. Keep each +hypothesis snippet, known-bad probe, and final check as a numbered script +file under the part directory (e.g. `/verify/s1_hypothesis.py`, +`s1_known_bad.py`, `s1_verify.py`), run it with `python` through the shell, +and persist intermediate state in files (JSON, pickle, or a captured package) +reloaded by the next script. The script file is the evidence: do not hide an +unproven check in a throwaway one-shot command whose text is lost. Use +`update_plan` to make the current stage's verification contract a real gate, +not a note. + +Role 2 creates the stable stage steps in the `Build & Verify` phase in the +order `S1 plan verifier`, `S1 model`, `S1 run verifier`, then `S2 ...`, and +so on. Use the exact step strings in every `update_plan` call. When Role 3 +enters a stage, submit the full plan with that step as the only `in_progress` +entry. If a stage is added after the original initialization, add its three +steps as one ordered batch in the next rewrite before starting the first one; +do not add a later stage's steps while the current stage is still open. A +`repair:` step for the current stage is the exception: add it next to the +owning step and keep the stage open. + +After the verifier contract, executable check, hypothesis evidence, and +known-bad proof are recorded in `BUILD_PLAN.md`, close the planning gate and +open the modeling gate in one rewrite: `S1 plan verifier` -> `completed`, +`S1 model` -> `in_progress`. Role 4 owns the model step; it must not be +`in_progress` while `S1 plan verifier` is unfinished. After Role 4's source +run completes, mark `S1 model` completed, set `S1 run verifier` +`in_progress`, and execute the exact prepared script named in that step. + +On a passing verification, record the output/evidence and mark `S1 run +verifier` completed; only then may `S2 plan verifier` become `in_progress`. +On a failed check, do not flip statuses merely to clear the list: identify +the owner, add a stable `repair:` step, make it the `in_progress` step, and +after the repair return to the current stage's `run verifier`; never advance +to `S2`. Batch each rewrite with the real shell, file, or verification work +in the same turn. + + + + + +### Host binding: file-based hypothesis loop and checklist state machine for Verifier Planner (dsh / kimicode) + +No persistent REPL or native TODO tool is assumed for this host. Python runs +through the shell and every invocation is a fresh process: names do not +survive between calls. Keep each hypothesis snippet, known-bad probe, and +final check as a numbered script file under the part directory (e.g. +`/verify/s1_hypothesis.py`, `s1_known_bad.py`, `s1_verify.py`), +run it with `python` through the shell, and persist intermediate state in +files (JSON, pickle, or a captured package) reloaded by the next script. The +script file is the evidence: do not hide an unproven check in a throwaway +one-shot command whose text is lost. + +Keep the stage checklist (`S1 plan verifier`, `S1 model`, `S1 run verifier`, +then `S2 ...`) as a fenced checklist section in `BUILD_PLAN.md` and treat it +as the gate ledger: when Role 3 enters a stage, mark that stage's +`plan verifier` item in-progress in the same turn; when the verifier +contract, executable check, hypothesis evidence, and known-bad proof are +recorded, mark `S1 plan verifier` done and `S1 model` in-progress in one +edit. Role 4 owns the model item and must not start while `S1 plan verifier` +is unfinished. After Role 4's source run completes, mark `S1 model` done, set +`S1 run verifier` in-progress, and execute the exact prepared script named in +that item. On a passing verification, record the output/evidence and mark +`S1 run verifier` done; only then may `S2 plan verifier` start. On a failed +check, do not edit statuses merely to clear the list: identify the owner, add +a stable `repair:` item next to the owning item, and after the repair return +to the current stage's `run verifier`; never advance to `S2`. Quote the +current checklist state when reporting a gate decision. Batch each checklist +edit with the real shell, file, or verification work in the same turn. + + diff --git a/docs/skill/references/workflows/single-part-modeling/role-3-verifier.md b/docs/skill/references/workflows/single-part-modeling/role-3-verifier.md new file mode 100644 index 00000000..d7a564ab --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-3-verifier.md @@ -0,0 +1,49 @@ +# Single-Part Modeling: Role 3, Verifier Planner + +Outline: `workflows/single-part-modeling.md`. Check patterns and the isolated image review: `workflows/single-part-modeling/verifier-contract-patterns.md`. +The per-host loop and TODO state machine for this role: `workflows/single-part-modeling/role-3-host-bindings.md` (read it when Role 3 starts). + +## Role 3 — Verifier Planner + +Role 3 runs before every stage's modeling work and owns the stage's +verification contract. It is the modeling equivalent of writing tests before +implementation. + +For the current `S` stage, in order: + +1. Identify the stage output: name the substructure that must be complete, + its datum/coordinate relationship, and the existing geometry it may touch. + State what is deliberately out of scope until later stages. +2. Translate the requirements into acceptance criteria for that output: + dimensions and tolerances; required faces, holes, axes, or features; + interfaces and fit with already-built geometry; collision and clearance; + fastener insertion and tool access; assembly reachability; demolding or + manufacturing access when relevant; absence of unintended interference; + BREP validity and single-solid expectations. Include visual criteria when +3. Turn every criterion into a deterministic check. Iterate through small + hypothesis code snippets or probes in the available execution environment: + deliberately good and bad probes must show that the proposed measurement, + collision test, topology query, or visual review contract distinguishes pass + from fail. Reject a check that merely prints a plausible value without + testing the requirement. +4. Produce the executable verification script or function for this stage, + including explicit pass/fail assertions, named measurements, and small + evidence prints. Append to `BUILD_PLAN.md`: + `Verifier contract: / Criteria: <...> / Script: ` + plus the hypothesis results and known-bad proof. +5. Update TODO before modeling: complete the current `plan verifier` item + after the contract, executable check, hypothesis evidence, and known-bad + proof are recorded. The `model` item may then start. After Role 4 produces + the stage result, start `run verifier` and execute the exact prepared + function or script against that result; only its passing output closes the + stage. + +Role 3 may prepare visual verification here. It defines named render views, +close-ups, comparison regions, and the structured verdict required from an +isolated reviewer. It does not treat a render it produced as its own visual +acceptance. +**Unattended:** the visual contract is still written before Role 4 builds (views, close-ups, regions, verdict fields). The +review is a fresh-context check against that written contract (a subagent if the host offers one), quoting what was +observed; if neither is possible the report says the review was not independent. A visual verdict still comes from outside +the context that produced the render, or is reported as not independent; it never comes from the model's own say-so. + diff --git a/docs/skill/references/workflows/single-part-modeling/role-4-build.md b/docs/skill/references/workflows/single-part-modeling/role-4-build.md new file mode 100644 index 00000000..c871e127 --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/role-4-build.md @@ -0,0 +1,158 @@ +# Single-Part Modeling: Role 4, Detail Modeling Planner & Builder + +Outline: `workflows/single-part-modeling.md`. + +## Role 4 — Detail Modeling Planner & Builder + +Role 4 starts only after Role 3's current-stage verifier contract and +executable check exist. It owns the detailed construction of the current +stage, not the acceptance definition. + +For the current stage: + +1. Read the verifier contract, then choose a construction strategy from the + geometry and the controlling dimensions. The categories below are a design + ladder, **not an API whitelist**: select the operations that express the + current stage's design intent, then read the exact documentation page for + every operation before its first call. A method absent from its page does + not exist. + + - **Profile and sketch first when they control the shape.** Analyze the + required 2D sections, symmetry, datum planes, and dimensional relations. + Use `make_sketch_rsketch` with the applicable `constrain_*` APIs and + `inspect_sketch_rsketchresult` when a constrained sketch carries design + intent; promote it through `make_wire_from_sketch_rwire` or + `make_face_from_sketch_rface`. Build solids from that intent with + `extrude_rsolid`, `revolve_rsolid`, `loft_rsolid`, `sweep_rsolid`, or a + helical sweep when the feature is genuinely helical. A closed profile is + preferred when its parameters directly represent the requirement. + - **Primitives are an alternative for genuinely regular base forms.** Use a + box, cylinder, cone, sphere, or other documented primitive when it maps + directly to the controlled form; do not force a sketch only because one + exists. Keep the controlling dimensions as named parameters either way. + - **Build named feature solids, then combine them deliberately.** Start + from a named base solid, create each additive boss/rib/flange or + subtractive bore/pocket/opening as a separately named solid or tool, then + use `union_rsolid`, `cut_rsolid`, or `intersect_rsolid` to obtain the next + single Solid. Repeat construct-feature -> Boolean -> verify for each + required feature group; ensure intentional positive overlap for unions + and tool overshoot for cuts. Do not assemble an opaque final shape in one + expression. + - **Use advanced features only after the prototype feature is proven.** + Validate one representative feature before `linear_pattern_rsolidlist` or + `radial_pattern_rsolidlist`; use documented transforms or mirrors for + symmetry. Apply `fillet_rsolid`, `chamfer_rsolid`, `shell_rsolid`, and + similar topology-sensitive features after their supporting geometry and + selectors are stable. + - **Use surface construction when the requirement is surface-led.** + Consider documented ruled, patch, Bezier, Gordon, cylindrical, trimmed, + sewn, or lofted surfaces; turn a closed, valid shell into a Solid only + when that is the required topology. Do not approximate a freeform or + transition surface with arbitrary primitives merely because booleans are + familiar. + +2. Name every meaningful construction step before it is used: source variables, + helper functions, exposed parameters, and tags must identify the part role + or feature rather than its creation order. Prefer names such as + `mounting_base`, `port_bore_tool`, `front_flange`, and + `feature.mounting_bore` over `shape2`, `cut1`, or numeric topology indexes. + Attach semantic tags with `apply_tag(shape=..., tag=...)`, inspect them with + `list_tags(shape=...)`, and use tag-aware QL selection where later features + or verification need stable intent. Use `role.*`, `feature.*`, and `tool.*` + namespaces; keep numeric facts in metadata rather than tags. + +3. Use small hypothesis code snippets or probes in the available execution + environment to test the proposed construction sequence on minimal solids. + Print types, counts, volumes, bounds, tag/selection cards, and other facts + required by the verifier contract. Adopt an operation only when its evidence + shows that it can produce the required shape. Append + `Hypothesis: ... / Result: ... / Adopted: ...` under the current stage in + `BUILD_PLAN.md`. +4. Edit the part source only after the operation hypotheses pass. Use keyword + arguments, named parameters, role/feature/tool tags, and QL fact prints; + include selection cards before every fillet/chamfer. +5. Run the source and hand the resulting stage geometry to Role 3. Role 3 + executes the exact prepared verification script. The stage passes only when + all assertions, measurements, fit/access checks, and required visual + verdicts pass. A visual verdict comes from the isolated reviewer contract + planned by Role 3. +5. On failure, do not weaken the check. Determine whether the verifier + contract or the model owns the defect, add a `repair:` TODO to that owner, + and repeat Role 3/4 for the same stage. Start the next stage only after the + current stage's `model` and `run verifier` items are both complete. + +**Unattended:** the visual verdict is the fresh-context check Role 3 planned, with the observation quoted +(`workflows/single-part-modeling/verifier-contract-patterns.md`); the stage passes only when it is recorded. A failure still reopens the owning artifact. + + + +### Host binding: persistent REPL for Detail Modeling Planner & Builder (omp) + +Use `eval` (language `py`) for incremental construction hypotheses; names +survive across calls. Keep the real source run in bash. Never use a modeling +success message as the verification result: invoke the prepared verifier +function or script and record its output in the stage evidence. + + + + + +### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (opencode) + +opencode has no persistent REPL; run incremental construction hypotheses as +numbered scripts under the part directory (e.g. +`/hypotheses/s1_base_probe.py`) through `bash`. Names do not +survive between runs: carry intermediate geometry between probes by writing +it to a file (pickle, JSON, or `capture`) and reloading, or by +re-materializing from the part source. Keep the real source run in `bash` +too. Never use a modeling success message as the verification result: +invoke the prepared verifier script and record its output in the stage +evidence. + + + + + +### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (Claude Code / ZCode) + +This host has no persistent REPL; run incremental construction hypotheses as +numbered scripts under the part directory (e.g. +`/hypotheses/s1_base_probe.py`) through the `Bash` tool. Names do +not survive between runs: carry intermediate geometry between probes by +writing it to a file (pickle, JSON, or a captured package) and reloading, or +by re-materializing from the part source. Keep the real source run in `Bash` +too. Never use a modeling success message as the verification result: invoke +the prepared verifier script and record its output in the stage evidence. + + + + + +### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (codex) + +codex has no persistent REPL; run incremental construction hypotheses as +numbered scripts under the part directory (e.g. +`/hypotheses/s1_base_probe.py`) through the shell tool. Names do +not survive between runs: carry intermediate geometry between probes by +writing it to a file (pickle, JSON, or a captured package) and reloading, or +by re-materializing from the part source. Keep the real source run in the +shell too. Never use a modeling success message as the verification result: +invoke the prepared verifier script and record its output in the stage +evidence. + + + + + +### Host binding: file-based hypothesis loop for Detail Modeling Planner & Builder (dsh / kimicode) + +No persistent REPL is assumed for this host; run incremental construction +hypotheses as numbered scripts under the part directory (e.g. +`/hypotheses/s1_base_probe.py`) through the shell. Names do not +survive between runs: carry intermediate geometry between probes by writing +it to a file (pickle, JSON, or a captured package) and reloading, or by +re-materializing from the part source. Keep the real source run in the shell +too. Never use a modeling success message as the verification result: invoke +the prepared verifier script and record its output in the stage evidence. + + diff --git a/docs/skill/references/workflows/single-part-modeling/verifier-contract-patterns.md b/docs/skill/references/workflows/single-part-modeling/verifier-contract-patterns.md new file mode 100644 index 00000000..c094cebc --- /dev/null +++ b/docs/skill/references/workflows/single-part-modeling/verifier-contract-patterns.md @@ -0,0 +1,153 @@ +# Single-Part Modeling: verifier contract patterns and image review + +**Read when:** Role 3 selects the checks for a stage. + +## Verifier contract patterns + +Role 3 selects the applicable checks per stage; it does not mechanically run +every pattern. A contract may combine: + +- dimensional measurements with explicit tolerance bounds; +- topology, axis, and face-location queries for required features; +- BREP closure, positive volume, and solid-count checks; +- swept insertion envelopes for screws, tools, or assembly access, with a + zero-collision criterion; +- clearance and interference checks against already-built substructures; +- demolding or tool-direction access checks when manufacturing intent requires; +- named renders and isolated per-region visual verdicts; +- functional motion or clearance checks when the part has moving interfaces; +- FEM only when service conditions are stated in `REQUIREMENTS.md`. + +Every selected pattern needs a deterministic pass/fail condition and a +known-bad hypothesis proof before the current stage is modeled. + +**Unattended:** the isolated-review bindings below that name the user as reviewer do not apply. Use the replacement in +`discipline/operating-modes.md`: a fresh-context check of your own against the written visual contract, using a +subagent if the host offers one, quoting the observation per region; if no independent context is possible the visual gate +still closes on the written observations, and the report says the review was not independent. A mismatch is never overridden. + + + +### Host binding: mandatory isolated image review (omp) + +When Role 3 selects visual verification, any image-based acceptance judgment +must be executed by a clean-context reviewer. The context that produced a +render cannot certify it. Role 3 defines the visual contract before Role 4 +builds: reference/render paths, named views, regions, required verdict fields, +and the reviewer input description. + +- Default: spawn one isolated `task` reviewer with file paths and the written + shape description from `REQUIREMENTS.md`; demand structured per-region + `match` / `mismatch` / `unreviewable` verdicts with reasons. +- If a standing reviewer peer exists, use `hub` to send the same contract and + record its reply verbatim. +- A mismatch cannot be overridden by the main context. Repair the owning + model, render again, and repeat the isolated review. +- If neither reviewer mechanism exists, the visual gate remains open and the + stage cannot pass. + + + + + +### Host binding: mandatory isolated image review (opencode) + +When Role 3 selects visual verification, any image-based acceptance judgment +must be executed by a subagent with a clean context. The context that +produced a render cannot certify it. Role 3 defines the visual contract +before Role 4 builds: reference/render paths, named views, regions, required +verdict fields, and the reviewer input description. + +- Default and only mechanism: spawn one isolated reviewer with the `task` + tool, `subagent_type: "general"` (fresh context, able to read image + files). Pass the render/reference file paths and the written shape + description from `REQUIREMENTS.md`; require structured per-region `match` + / `mismatch` / `unreviewable` verdicts with reasons in the reviewer's + final report, and quote that report verbatim in the stage evidence. Spawn + a fresh reviewer for every re-review; do not resume a previous reviewer + session (task-id reuse would carry the context of the earlier render it + already judged). +- opencode has no standing-reviewer-peer mechanism; the hub option cannot + be mapped. If the `task` tool is unavailable, the visual gate remains + open and the stage cannot pass. +- A mismatch cannot be overridden by the main context. Repair the owning + model, render again, and repeat the isolated review. + + + + + +### Host binding: mandatory isolated image review (Claude Code / ZCode) + +When Role 3 selects visual verification, any image-based acceptance judgment +must be executed by a subagent with a clean context. The context that +produced a render cannot certify it. Role 3 defines the visual contract +before Role 4 builds: reference/render paths, named views, regions, required +verdict fields, and the reviewer input description. + +- Default and only mechanism: spawn one isolated reviewer subagent (ZCode + `Agent` tool / Claude Code `Task` tool, `subagent_type: "general-purpose"` + — fresh context, able to read image files). Pass the render/reference file + paths and the written shape description from `REQUIREMENTS.md`; require + structured per-region `match` / `mismatch` / `unreviewable` verdicts with + reasons in the reviewer's final report, and quote that report verbatim in + the stage evidence. The subagent's final message is the review record. +- Spawn a fresh reviewer for every re-review; do not resume a previous + reviewer session or reuse a task id (its context carries the earlier + render it already judged, so it can no longer certify a new one). +- This host has no standing-reviewer-peer mechanism; the hub option cannot + be mapped. If the subagent spawn tool is unavailable, the visual gate + remains open and the stage cannot pass. +- A mismatch cannot be overridden by the main context. Repair the owning + model, render again, and repeat the isolated review. + + + + + +### Host binding: mandatory isolated image review (codex) + +When Role 3 selects visual verification, any image-based acceptance judgment +must come from a context that did not produce the render; the modeling +context cannot certify it. Role 3 defines the visual contract before Role 4 +builds: reference/render paths, named views, regions, required verdict +fields, and the reviewer input description. + +- codex has no subagent spawn mechanism. The fallback reviewer is the user: + present the render together with the written visual contract (views, + regions, and the verdict fields you need) in one message, then stop and + wait. Record the user's verdict verbatim in the stage evidence as the + review record; a partial verdict is `unreviewable` for the uncovered + regions. +- If the user cannot review in this round, the visual gate remains open and + the stage cannot pass; do not substitute the modeling context's own + judgment for the review. +- A mismatch cannot be overridden by the modeling context. Repair the owning + model, render again, and repeat the review. + + + + + +### Host binding: mandatory isolated image review, conservative fallback (dsh / kimicode) + +When Role 3 selects visual verification, any image-based acceptance judgment +must come from a context that did not produce the render; the modeling +context cannot certify it. Role 3 defines the visual contract before Role 4 +builds: reference/render paths, named views, regions, required verdict +fields, and the reviewer input description. + +- No subagent mechanism is assumed for this host. If the host provides one, + spawn an isolated reviewer subagent with fresh context per re-review, pass + the render/reference paths and the written shape description, require + structured per-region `match` / `mismatch` / `unreviewable` verdicts with + reasons, and quote its report verbatim in the stage evidence. +- Without a subagent mechanism, the fallback reviewer is the user: present + the render together with the written visual contract in one message, then + stop and wait; record the user's verdict verbatim as the review record. If + the user cannot review in this round, the visual gate remains open and the + stage cannot pass. +- A mismatch cannot be overridden by the modeling context. Repair the owning + model, render again, and repeat the review. + + diff --git a/docs/skill/references/workflows/step-reconstruction.md b/docs/skill/references/workflows/step-reconstruction.md index 475af5d1..821c995b 100644 --- a/docs/skill/references/workflows/step-reconstruction.md +++ b/docs/skill/references/workflows/step-reconstruction.md @@ -10,6 +10,43 @@ Use when the deliverable is an editable/replayable model reproducing a target `.step`/`.stp`. Not for read-only questions about a STEP file — those stay in `domains/step-inspection.md` without reconstruction. +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `workflows/step-reconstruction.md` | this page: steps, gates, failure routes | +| `domains/step-inspection.md` | inspection primitives, comparison ladder, acceptance hierarchy | +| `inspect/brep-reverse-engineering.md` | normative: read completely before any inspection or comparison | + +## Read when + +| When | File | +| --- | --- | +| the fidelity, scope or acceptance asked for is unclear beyond step 1 | `domains/requirement-refinement.md` | +| step 3: inferring feature families and provenance rows | `docs/guides/reconstruction-agent-strategy.md` | +| the run is a controlled reconstruction trial | `docs/guides/reconstruction-agent-test-prompt.md` | +| step 4: building the candidate | `workflows/single-part-modeling.md`, `domains/part-modeling.md` | +| authoring or editing the candidate source | `discipline/feature-tree-convention.md`, `docs/guides/notebook-runtime.md` | +| splitting the candidate into cells | `discipline/notebook-cell-design.md` | +| constraints represent the evidence | `domains/sketch-and-features.md` | +| the target has several components | `domains/assembly-and-product.md`, `workflows/assembly-product-build.md` | +| re-exporting for comparison or delivery | `domains/export-and-translation.md` | +| a comparison fails or a feature is falsified | `discipline/failure-and-repair.md` | +| the source is a mesh, point cloud or photos, not a STEP | `workflows/scan-reconstruction.md` | + +## Unattended mode + +| Interactive gate | Unattended replacement | +| --- | --- | +| Step 1: ask what fidelity (exact transcription vs functional equivalence), which components, what acceptance means | Record each as an assumption in the ledger; default to the strictest acceptance the hierarchy allows (topology identity) and the whole file in scope | +| The target STEP, or a component of it, is missing or unreadable | Record the blocker in the report; reconstruct only what the readable evidence supports; never invent the target | +| Reconstruction tooling absent (for example the `drawing` extra) | Report the missing capability, do the strongest work without it, say which parts it supports | +| Step 7: a structurally different but visually close stop | Allowed only with the recorded evidence the hierarchy demands (no better feature order exists or the SDK lacks the operation); the report states the acceptance tier reached | +| A person looks at comparison renders | Look yourself: name the views (`render_step_comparison_rpath`), what you looked for, quote the observation; strict BREP comparison still decides | +| A second reviewer of the verdict | Fresh-context checklist pass quoting observations (subagent if offered); otherwise the report says the review was not independent | +| Same comparison failure twice for the same cause | Change the feature hypothesis or redefine the check in the report (`discipline/failure-and-repair.md`) | +| Closing message to the person | `report.md` with the acceptance tier, iteration rows, unrecovered regions, reproduce commands (`discipline/operating-modes.md`) | + ## Task decomposition ```yaml @@ -42,6 +79,7 @@ repair_routes: 1. **Refine the requirement**: what fidelity is required (exact transcription vs functional equivalence), which components are in scope, what acceptance means here. + **Unattended:** record each as an assumption; default to topology identity over the whole file. 2. **Inventory the target economically** (`domains/step-inspection.md`): `inspect_step_rsummary` (with `include_parameter_groups=True` once), entity descriptors for specific @@ -85,10 +123,12 @@ repair_routes: failed strict comparison, group causes with `BRepComparison.to_error_summary()`, fix related groups together, rerun a fresh cycle. + **Unattended:** look at the renders yourself and quote what you saw; the same failure twice changes the hypothesis. 7. **Accept per the hierarchy** (`domains/step-inspection.md`): topology identity is the endpoint; float-level serialization drift is acceptable; a structurally different but visually close stop requires recorded evidence that no better feature order exists or the SDK lacks the operation. + **Unattended:** the report names the acceptance tier reached. ## Validation gates @@ -107,11 +147,13 @@ repair_routes: - Reconstruction tooling absent (for example the `drawing` extra, which `simplecadapi.inspect.drawing` needs, is not installed) → report the missing capability rather than degrading silently. + **Unattended:** the report names the capability and what it would have covered. ## Deliverables Candidate source path, replay evidence, comparison evidence with verdict and its acceptance tier, provenance rows, and explicitly unrecovered regions. +**Unattended:** these go in `report.md` (`discipline/operating-modes.md`). **Required reading before authoring or editing any part source in this workflow:** `discipline/feature-tree-convention.md` — the block-structured diff --git a/skillproj.toml b/skillproj.toml index a63b1520..fc3d208f 100644 --- a/skillproj.toml +++ b/skillproj.toml @@ -2,6 +2,9 @@ name = "simplecadapi" source = "docs/skill" +[lint] +enabled = true # docs/skill-design.md: page limits, headers, read sets, read budget + [build] targets = ["omp", "opencode", "zcode", "claudecode", "codex", "dsh", "kimicode"] diff --git a/src/simplecadapi/skill/compiler.py b/src/simplecadapi/skill/compiler.py index a430aa20..8d3a441b 100755 --- a/src/simplecadapi/skill/compiler.py +++ b/src/simplecadapi/skill/compiler.py @@ -133,6 +133,7 @@ class SkillProject: source: Path targets: tuple[str, ...] outputs: dict + lint: bool = False # [lint] enabled = true: --check also enforces the structure contract @dataclass(frozen=True) @@ -203,7 +204,9 @@ def load_project(root: Path) -> SkillProject: outputs[target] = (root / entry).resolve() _validate_output_paths(root.resolve(), source, outputs) - return SkillProject(root.resolve(), name, source, tuple(targets), outputs) + lint_table = data.get("lint", {}) + lint_on = isinstance(lint_table, dict) and lint_table.get("enabled") is True + return SkillProject(root.resolve(), name, source, tuple(targets), outputs, lint_on) def _validate_output_paths(root: Path, source: Path, outputs: dict) -> None: @@ -426,6 +429,11 @@ def main(argv=None) -> int: action="store_true", help="validate the tree without writing outputs", ) + parser.add_argument( + "--read-budget", + action="store_true", + help="print the compiled size of each route's first read batch and exit", + ) args = parser.parse_args(argv) try: @@ -434,7 +442,20 @@ def main(argv=None) -> int: source_files = collect_source_files( project.source, frozenset(project.targets), project.root ) + if args.read_budget: + from .lint import read_budget_report + + print("\n".join(read_budget_report(project, source_files))) + return 0 if args.check: + from .lint import lint + + problems = lint(project, source_files) if project.lint else [] + if problems: + for problem in problems: + print(f"skill lint: {problem}", file=sys.stderr) + print(f"error: {len(problems)} skill lint problem(s)", file=sys.stderr) + return 1 conditional = sum(1 for entry in source_files if entry.blocks) print( f"OK: {len(source_files)} files, {conditional} with conditional " diff --git a/src/simplecadapi/skill/lint.py b/src/simplecadapi/skill/lint.py new file mode 100644 index 00000000..65d1798b --- /dev/null +++ b/src/simplecadapi/skill/lint.py @@ -0,0 +1,235 @@ +"""Structure checks for the skill source tree (the contract is docs/skill-design.md). + +``lint(project, source_files)`` returns a list of problems; ``skillbuild --check`` fails when it is not empty. +""" + +from __future__ import annotations + +import posixpath +import re +from collections import deque +from typing import Dict, List, Optional, Set + +from .compiler import SkillProject, SourceFile, compile_text + +MAX_PAGE_LINES = 300 +MAX_ROUTER_LINES = 130 +# SKILL.md + the always-read pages + the workflow outline + its Read now files, compiled. +READ_BUDGET_LINES = 1200 +ALWAYS_READ = ("references/discipline/core-rules.md", "references/discipline/operating-modes.md") +GENERATED_PREFIXES = ("references/docs/api/", "references/docs/stdlib/", "references/docs/core/") +SECTION_READ_NOW = "## Read now (one parallel batch)" +SECTION_READ_WHEN = "## Read when" +SECTION_UNATTENDED = "## Unattended mode" +PARALLEL_HEADING = "## Reading in parallel" +PARALLEL_MARKER = { + "claudecode": "one message", + "zcode": "one message", + "opencode": "one response", + "codex": "tail -n +1", +} +TOP_DIRS = ("workflows/", "domains/", "discipline/", "docs/", "inspect/") +TOP_FILES = ("ql-playbook.md", "ql-recipes.md", "SDK_OVERVIEW.md", "scadpkg-format.md", "README.md") + +_BACKTICK = re.compile(r"`([^`\n]+)`") +_LINK = re.compile(r"\]\(([^)\s#]+\.md)(?:#[^)]*)?\)") + + +def _normalise(token: str) -> Optional[str]: + """A source-relative path for a backticked token that looks like a reference path, else None.""" + token = token.strip() + if not token.endswith(".md") or any(c in token for c in "<>*{}$ ") or "..." in token: + return None + if token.startswith("references/"): + return token + if token.startswith(TOP_DIRS) or token in TOP_FILES: + return "references/" + token + if token == "SKILL.md": + return token + return None + + +def _refs(page: str, text: str, known: Set[str]) -> Set[str]: + out: Set[str] = set() + for token in _BACKTICK.findall(text): + path = _normalise(token) + if path: + out.add(path) + for target in _LINK.findall(text): + if "://" in target: + continue + joined = posixpath.normpath(posixpath.join(posixpath.dirname(page), target)) + out.add(joined) + return out + + +def _table_paths(text: str, heading: str) -> List[str]: + """Paths in backticks in the table under ``heading``, first column or any column, in order.""" + lines = text.splitlines() + try: + start = next(i for i, line in enumerate(lines) if line.strip() == heading) + except StopIteration: + return [] + paths: List[str] = [] + for line in lines[start + 1 :]: + if line.startswith("## "): + break + if line.startswith("|"): + for token in _BACKTICK.findall(line): + path = _normalise(token) + if path: + paths.append(path) + return paths + + +def _compiled_lines(entry: SourceFile, target: str) -> int: + text = compile_text(entry.text, target, entry.blocks or []) + return len(text.splitlines()) + + +def lint(project: SkillProject, files: List[SourceFile]) -> List[str]: + problems: List[str] = [] + source = { + "/".join(entry.relative.parts): entry + for entry in files + if entry.text is not None and entry.relative.suffix == ".md" + } + hand = { + name: entry + for name, entry in source.items() + if not name.startswith(GENERATED_PREFIXES) and name != "references/LICENSE.txt" + } + known = set(source) + + # 1. every named path exists; 3. reachability + graph: Dict[str, Set[str]] = {} + for name, entry in hand.items(): + refs = _refs(name, entry.text, known) + graph[name] = {r for r in refs if not r.startswith(GENERATED_PREFIXES)} + for ref in sorted(refs): + if ref in known or ref.startswith(GENERATED_PREFIXES) or ref.endswith("/"): + continue + problems.append(f"{name}: names {ref}, which does not exist") + if "SKILL.md" in hand: + seen = {"SKILL.md"} + queue = deque(["SKILL.md"]) + while queue: + for nxt in graph.get(queue.popleft(), ()): + if nxt in hand and nxt not in seen: + seen.add(nxt) + queue.append(nxt) + for name in sorted(set(hand) - seen): + problems.append(f"{name}: not reachable from SKILL.md") + else: + problems.append("SKILL.md is missing") + + # 4. line limits + for name, entry in hand.items(): + if "\nContract ID:" in "\n".join(entry.text.splitlines()[:6]): + continue # a versioned run contract is read whole by an external harness, not routed + limit = MAX_ROUTER_LINES if name == "SKILL.md" else MAX_PAGE_LINES + count = len(entry.text.splitlines()) + if count > limit: + problems.append(f"{name}: {count} lines (limit {limit})") + + # 2. README lists every workflow, domain and discipline page + readme = hand.get("references/README.md") + listed = readme.text if readme else "" + for name in sorted(hand): + if name.startswith(("references/workflows/", "references/domains/", "references/discipline/")): + short = name[len("references/") :] + if short not in listed: + problems.append(f"references/README.md does not list {short}") + + # 5. section and header contracts + workflows = { + n: e for n, e in hand.items() if n.startswith("references/workflows/") and n.count("/") == 2 + } + for name, entry in workflows.items(): + for heading in (SECTION_READ_NOW, SECTION_READ_WHEN, SECTION_UNATTENDED): + if heading not in entry.text.splitlines(): + problems.append(f"{name}: missing section '{heading}'") + for name, entry in hand.items(): + if name.startswith(("references/domains/", "references/discipline/")): + head = "\n".join(entry.text.splitlines()[:8]) + if "**Read when:**" not in head: + problems.append(f"{name}: no **Read when:** line in the first 8 lines") + + # 6. route table agrees with each workflow's Read now set + router = hand.get("SKILL.md") + read_now: Dict[str, List[str]] = {} + if router: + for line in router.text.splitlines(): + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3: + continue + wf = [p for p in (_normalise(t) for t in _BACKTICK.findall(cells[1])) if p] + if not wf or not wf[0].startswith("references/workflows/"): + continue + row = [p for p in (_normalise(t) for t in _BACKTICK.findall(cells[2])) if p] + read_now[wf[0]] = row + for name in workflows: + if name not in read_now: + problems.append(f"SKILL.md: route table has no row for {name}") + for name, row in read_now.items(): + entry = workflows.get(name) + if entry is None: + continue + expected = [name] + _table_paths(entry.text, SECTION_READ_NOW) + if sorted(set(row)) != sorted(set(expected)): + extra = sorted(set(row) - set(expected)) + lack = sorted(set(expected) - set(row)) + problems.append( + f"SKILL.md route for {name} differs from its Read now set (extra {extra}, missing {lack})" + ) + + # 7. read budget, per route and target + for name, row in read_now.items(): + for target in project.targets: + batch = ["SKILL.md", *ALWAYS_READ, *row] + total = 0 + for item in dict.fromkeys(batch): + entry = source.get(item) + if entry is not None: + total += _compiled_lines(entry, target) + if total > READ_BUDGET_LINES: + problems.append( + f"{name} on {target}: first batch is {total} compiled lines (budget {READ_BUDGET_LINES})" + ) + + # 8. parallel-read instruction; 9. directives are resolved by compile_text, so check leftovers + if router: + for target in project.targets: + compiled = compile_text(router.text, target, router.blocks or []) + if PARALLEL_HEADING not in compiled: + problems.append(f"SKILL.md on {target}: no '{PARALLEL_HEADING}' section") + marker = PARALLEL_MARKER.get(target) + if marker and marker not in compiled: + problems.append(f"SKILL.md on {target}: parallel-read text lacks '{marker}'") + if "skill:if" in compiled or "skill:endif" in compiled: + problems.append(f"SKILL.md on {target}: unresolved skill directive") + return problems + + +def read_budget_report(project: SkillProject, files: List[SourceFile]) -> List[str]: + """One line per route and target with the compiled size of its first batch (for reviewers).""" + source = {"/".join(e.relative.parts): e for e in files if e.text is not None} + router = source.get("SKILL.md") + out: List[str] = [] + if router is None: + return out + for line in router.text.splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if not line.startswith("|") or len(cells) < 3: + continue + wf = [p for p in (_normalise(t) for t in _BACKTICK.findall(cells[1])) if p] + if not wf: + continue + row = [p for p in (_normalise(t) for t in _BACKTICK.findall(cells[2])) if p] + for target in project.targets: + batch = list(dict.fromkeys(["SKILL.md", *ALWAYS_READ, *row])) + total = sum(_compiled_lines(source[i], target) for i in batch if i in source) + out.append(f"{wf[0]:60s} {target:10s} {len(batch):2d} files {total:5d} lines") + return out diff --git a/test/test_drawing_skill_integration.py b/test/test_drawing_skill_integration.py index 3bb1b750..2687adc8 100644 --- a/test/test_drawing_skill_integration.py +++ b/test/test_drawing_skill_integration.py @@ -26,9 +26,11 @@ def test_drawing_workflow_is_complete_in_each_host_build(target, tmp_path): router = (output / "SKILL.md").read_text(encoding="utf-8") assert "references/workflows/drawing-reconstruction.md" in router assert "references/domains/addon-development.md" in router - workflow = (output / "references/workflows/drawing-reconstruction.md").read_text( - encoding="utf-8" - ) + # the workflow is an outline plus one page per stage (docs/skill-design.md) + pages = [output / "references/workflows/drawing-reconstruction.md"] + pages += sorted((output / "references/workflows/drawing-reconstruction").glob("*.md")) + pages += [output / "references/domains/drawing-inspection-evidence.md"] + workflow = "\n".join(page.read_text(encoding="utf-8") for page in pages) assert "section_checks" in workflow and "measurement_contract" in workflow assert "Host binding:" in workflow assert " +Issue all Read calls of the batch in one message. + + +Issue all read calls of the batch in one response. + + +Run `tail -n +1 a.md b.md`. + + +Request the whole batch together. + + +| Goal | Workflow | Read now | +| --- | --- | --- | +| Model a part | `references/workflows/part.md` | `references/workflows/part.md` `references/domains/d.md` | +""" + +WORKFLOW = """# Workflow: part + +## Read now (one parallel batch) + +| File | Why | +| --- | --- | +| `domains/d.md` | the domain | + +## Read when + +| When | File | +| --- | --- | +| later | `discipline/core-rules.md` | + +## Unattended mode + +No gate asks a person. +""" + +PAGES = { + "SKILL.md": ROUTER, + "references/README.md": "workflows/part.md domains/d.md discipline/core-rules.md discipline/operating-modes.md\n", + "references/workflows/part.md": WORKFLOW, + "references/domains/d.md": "# D\n\n**Read when:** a part is made.\n", + "references/discipline/core-rules.md": "# Core\n\n**Read when:** always.\n", + "references/discipline/operating-modes.md": "# Modes\n\n**Read when:** always.\n", +} +TARGETS = ["claudecode", "zcode", "opencode", "codex", "omp", "dsh", "kimicode"] + + +def _problems(root: Path, pages: dict) -> list: + source = root / "docs" / "skill" + for name, text in pages.items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + outputs = "\n".join(f'{t} = "skills/demo-{t}"' for t in TARGETS) + (root / "skillproj.toml").write_text( + '[skill]\nname = "demo"\nsource = "docs/skill"\n\n[build]\n' + f"targets = {TARGETS!r}\n\n[build.output]\n{outputs}\n".replace("'", '"') + ) + project = load_project(root) + return lint(project, collect_source_files(project.source, frozenset(project.targets), root)) + + +def test_a_consistent_tree_has_no_problems(tmp_path): + assert _problems(tmp_path, PAGES) == [] + + +@pytest.mark.parametrize( + "mutate, expected", + [ + (lambda p: p.update({"references/domains/d.md": "# D\n"}), "no **Read when:**"), + ( + lambda p: p.update({"references/workflows/part.md": WORKFLOW.replace("## Unattended mode", "## Other")}), + "missing section '## Unattended mode'", + ), + ( + lambda p: p.update({"references/workflows/part.md": WORKFLOW + "\nSee `domains/missing.md`.\n"}), + "does not exist", + ), + ( + lambda p: p.update({"references/domains/orphan.md": "# O\n\n**Read when:** never.\n"}), + "not reachable", + ), + (lambda p: p.update({"references/README.md": "workflows/part.md\n"}), "does not list"), + ( + lambda p: p.update({"references/domains/d.md": "# D\n\n**Read when:** x.\n" + "x\n" * 300}), + "limit 300", + ), + ( + lambda p: p.update({"SKILL.md": ROUTER.replace(" `references/domains/d.md`", "")}), + "differs from its Read now set", + ), + (lambda p: p.update({"SKILL.md": ROUTER.replace("## Reading in parallel", "## Reading")}), "no '## Reading in parallel'"), + (lambda p: p.update({"SKILL.md": ROUTER.replace("in one message", "together")}), "lacks 'one message'"), + ], +) +def test_each_rule_fails_on_a_tree_that_breaks_it(tmp_path, mutate, expected): + pages = dict(PAGES) + mutate(pages) + if expected == "not reachable": + pages["references/README.md"] += "domains/orphan.md\n" + problems = _problems(tmp_path, pages) + assert any(expected in p for p in problems), problems + + +def test_the_first_batch_budget_is_enforced(tmp_path): + pages = dict(PAGES) + pages["references/domains/d.md"] = "# D\n\n**Read when:** x.\n" + "x\n" * 294 + pages["references/discipline/core-rules.md"] = "# C\n\n**Read when:** x.\n" + "x\n" * 294 + pages["references/discipline/operating-modes.md"] = "# M\n\n**Read when:** x.\n" + "x\n" * 294 + pages["references/workflows/part.md"] = WORKFLOW + "x\n" * 285 + problems = _problems(tmp_path, pages) + assert any("first batch is" in p for p in problems), problems + + +def test_the_real_skill_tree_satisfies_its_contract(): + root = Path(__file__).resolve().parents[1] + if not (root / "skillproj.toml").exists(): + pytest.skip("not a source checkout") + project = load_project(root) + files = collect_source_files(project.source, frozenset(project.targets), root) + assert lint(project, files) == [] From 22b0fb73238275d977336dd561a7f06c759172ae Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Sun, 1 Nov 2026 01:19:08 +0800 Subject: [PATCH 002/131] fix(ql): selectors, predicates and keys pickle, so a cell may return one and still be cached The 2.2.6 ShapeSelector was a frozen dataclass and pickled; the pybind class had no pickle support, so marimo's cell cache marked every cell returning a selector unserializable and the notebook ran again each time. The dict form (to_dict/from_dict) is the state. Co-Authored-By: Claude Sonnet 5.5 --- src/cpp/bindings/python/bind_ql.cpp | 10 +++++++ test/test_cell_cache_store.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/cpp/bindings/python/bind_ql.cpp b/src/cpp/bindings/python/bind_ql.cpp index 4b96660e..11f540cf 100644 --- a/src/cpp/bindings/python/bind_ql.cpp +++ b/src/cpp/bindings/python/bind_ql.cpp @@ -316,6 +316,9 @@ void bind_predicate(py::module_& q) { }, py::arg("obj")) .def("to_dict", [](const ql::Predicate& p) { return dict_of(p.to_value()); }) + // pickle / copy: the dict form is the whole state (marimo's cell cache pickles every returned value). + .def(py::pickle([](const ql::Predicate& p) { return dict_of(p.to_value()); }, + [](const py::dict& state) { return ql::Predicate::from_value(state.cast()); })) .def_static( "from_dict", [](const Value& payload) { return ql::Predicate::from_value(payload); }, py::arg("data")) .def( @@ -350,6 +353,8 @@ void bind_predicate(py::module_& q) { }, py::arg("obj")) .def("to_dict", [](const ql::Key& k) { return dict_of(k.to_value()); }) + .def(py::pickle([](const ql::Key& k) { return dict_of(k.to_value()); }, + [](const py::dict& state) { return ql::Key::from_value(state.cast()); })) .def_static( "from_dict", [](const Value& payload) { return ql::Key::from_value(payload); }, py::arg("data")) .def( @@ -363,6 +368,8 @@ void bind_predicate(py::module_& q) { .def(py::init([](const std::string& relation) { return ql::TraversalSpec{relation}; }), py::arg("relation")) .def_readonly("relation", &ql::TraversalSpec::relation) .def("to_dict", [](const ql::TraversalSpec& t) { return dict_of(t.to_value()); }) + .def(py::pickle([](const ql::TraversalSpec& t) { return dict_of(t.to_value()); }, + [](const py::dict& state) { return ql::TraversalSpec::from_value(state.cast()); })) .def_static( "from_dict", [](const Value& payload) { return ql::TraversalSpec::from_value(payload); }, py::arg("data")) .def("__eq__", [](const ql::TraversalSpec& a, const ql::TraversalSpec& b) { return a == b; }, py::is_operator()); @@ -646,6 +653,9 @@ void bind_selector(py::module_& q) { }, py::arg("scope"), "The shapes this query picks inside `scope` (a shape or a list of shapes).") .def("to_dict", [](const ql::ShapeSelector& s) { return dict_of(s.to_value()); }) + // pickle / copy: the dict form is the whole state (marimo's cell cache pickles every returned value). + .def(py::pickle([](const ql::ShapeSelector& s) { return dict_of(s.to_value()); }, + [](const py::dict& state) { return ql::ShapeSelector::from_value(state.cast()); })) .def_static( "from_dict", [](const Value& payload) { return ql::ShapeSelector::from_value(payload); }, py::arg("data")) .def( diff --git a/test/test_cell_cache_store.py b/test/test_cell_cache_store.py index 0784e92d..099e404b 100644 --- a/test/test_cell_cache_store.py +++ b/test/test_cell_cache_store.py @@ -110,3 +110,44 @@ def test_a_derived_expression_is_keyed_by_its_content_too(self) -> None: self.assertEqual(statuses, ["ran", "ran", "cached", "cached"]) changed = self.run_edit(derived, derived.replace("a * 2.0", "a * 3.0")) self.assertEqual(changed[2:], ["ran", "ran"]) + + +SELECTOR = notebook_text( + cell("plate = scad.make_box_rsolid(20.0, 10.0, 5.0)", returns="plate"), + cell( + """ + sel_top = scad.ql.faces().where(scad.ql.prop("geom.type", "==", "PLANE")).order_by(scad.ql.key("geom.center.z"), desc=True).take(1) + """, + returns="sel_top", + ), + cell("top = sel_top.resolve(plate)", reads="plate, sel_top", returns="top"), + cell("part = scad.Part(part_id='sel', body=plate)", reads="plate", returns="part"), + table='id = "sel"', +) + + +class TestSelectorIsCacheable(NotebookTestCase): + """A cell may return a QL selector (not yet resolved): the cache pickles it, as it did when the selector was a + Python dataclass.""" + + def test_selector_pickles_and_roundtrips(self) -> None: + import copy + import pickle + + from simplecadapi import ql + + selector = ql.edges().where(ql.prop("geom.type", "==", "LINE")).order_by(ql.key("geom.center.z")).take(2) + for protocol in range(2, pickle.HIGHEST_PROTOCOL + 1): # pybind11 pickling needs protocol 2+ + self.assertEqual(pickle.loads(pickle.dumps(selector, protocol)), selector) + self.assertEqual(copy.deepcopy(selector), selector) + predicate, key = ql.prop("geom.type", "==", "LINE"), ql.key("geom.center.z") + self.assertEqual(pickle.loads(pickle.dumps(predicate)), predicate) + self.assertEqual(pickle.loads(pickle.dumps(key)), key) + + def test_a_cell_returning_a_selector_is_cached(self) -> None: + path = self.write("sel.py", SELECTOR) + first = run_notebook(path) + second = run_notebook(path) + self.assertNotIn("cached", self.statuses(first)) + # the first status is the setup cell, which always runs + self.assertEqual(self.statuses(second), ["ran"] + ["cached"] * (len(self.statuses(first)) - 1)) From 72f8f7d949e8d7fa5290887b4911924cfa44870e Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Mon, 2 Nov 2026 13:07:21 +0800 Subject: [PATCH 003/131] fix(cache): a cell's node record is kept per cache entry, so another run of the cell cannot replace it One cell has a cache entry for every combination of values it reads (a child notebook run with other overrides, a parameter changed in the editor), all under one digest directory, and the nodes of two entries carry the same ids with other contents. The nodes were written to lineage., so the entry run last replaced the record of the others. Restoring another entry then registered nodes that did not belong to its values: a later cell failed with 'not owned by active graph' (ids the other run lacked) or bound to the other run's node of the same id. Records are now named by the entry's key; records written by earlier versions are not read and their entries run again. Co-Authored-By: Claude Sonnet 5.5 --- src/simplecadapi/runtime/cache.py | 15 ++++-- src/simplecadapi/runtime/lineage.py | 21 +++++--- test/test_cache_lineage_entries.py | 78 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 test/test_cache_lineage_entries.py diff --git a/src/simplecadapi/runtime/cache.py b/src/simplecadapi/runtime/cache.py index 3558a9d6..6afc296f 100644 --- a/src/simplecadapi/runtime/cache.py +++ b/src/simplecadapi/runtime/cache.py @@ -501,7 +501,7 @@ def _load_lineage(self, cell: CellImpl, digest: str) -> bool: try: # The entries of a run live in //cache: the nodes of its cells belong to the graph of that id. - return lineage.load(self._root / digest, str(cell.cell_id), self._root.parent.name) + return lineage.load(self._root / digest, str(cell.cell_id), self._root.parent.name, self._entry_key(cell)) except Exception as error: # noqa: BLE001 - an unreadable record is a miss, never a failure say(f"unreadable lineage record of cell {cell.cell_id} ({type(error).__name__}: {error}); running it again") return False @@ -525,16 +525,17 @@ def teardown(self, cell: CellImpl, glbls: MutableGlobals, run_result: RunResult) glbls[name] = None glbls[f"_cell_{cell.cell_id}_{name[1:]}"] = None live = glbls + entry_key = self._entry_key(cell) # (marimo forgets the attempt when it tears the cell down) super().teardown(cell, glbls, run_result) if run_result.exception is None: - self._save_lineage(cell, loader) + self._save_lineage(cell, loader, entry_key) if attempt is not None and not attempt.hit and self._memory is not None: # what this run made is what the entry names: the next evaluation with the same inputs takes it values = {var: (None if var.startswith("_") else live.get(lookup)) for var, lookup in attempt.contextual_defs()} if not any(is_unhashable_stub(value) for value in values.values()): self._memory.remember(loader, attempt.key, values, dict(attempt.meta), attempt.stateful_refs) - def _save_lineage(self, cell: CellImpl, loader: LazyLoader) -> None: + def _save_lineage(self, cell: CellImpl, loader: LazyLoader, entry_key: str | None) -> None: from . import executor, lineage session = executor.take_session(cell.cell_id) @@ -543,7 +544,13 @@ def _save_lineage(self, cell: CellImpl, loader: LazyLoader) -> None: return # a restored cell keeps the record it was restored with if session.graph.graph_id != self._root.parent.name: return # recorded under another id: not this run's lineage to keep - lineage.save(self._root / digest, str(cell.cell_id), session) + lineage.save(self._root / digest, str(cell.cell_id), session, entry_key) + + def _entry_key(self, cell: CellImpl) -> str | None: + """The key of the entry this cell restores or writes: its record of nodes is named by it (see :mod:`.lineage`).""" + + attempt = self._attempts.get(cell.cell_id) + return getattr(attempt, "hash", None) def _invalidate(self, cell_id: CellId_t) -> None: # The producer's entry lives in the store of its own digest. diff --git a/src/simplecadapi/runtime/lineage.py b/src/simplecadapi/runtime/lineage.py index d85bc64a..0d159e04 100644 --- a/src/simplecadapi/runtime/lineage.py +++ b/src/simplecadapi/runtime/lineage.py @@ -6,6 +6,11 @@ back into the process-wide node registry when the cell is restored. Cells restore in dependency order, so the nodes a cell's own nodes are built on are registered by then. + +A record belongs to one cache *entry* of the cell, not to the cell: one cell has an entry for every combination of the +values it reads (a child notebook run with other overrides, a parameter changed in the editor), all under one digest +directory, and the nodes of two entries carry the same ids (the ids count from the cell's code) with other contents. +The record is named by the entry's key, so restoring an entry registers the nodes that entry's values name. """ from __future__ import annotations @@ -23,8 +28,12 @@ def _own(node_id: str, namespace: str) -> bool: return node_id.startswith(namespace + "_") or node_id.startswith(_EXTERNAL) -def save(directory: Path, cell_id: str, session: Any) -> None: - """Write the nodes *session* recorded (not the ones it adopted) beside the cell's entry.""" +def _record(directory: Path, cell_id: str, key: str | None) -> Path: + return directory / (f"{_PREFIX}{cell_id}" if key is None else f"{_PREFIX}{cell_id}.{key}") + + +def save(directory: Path, cell_id: str, session: Any, key: str | None = None) -> None: + """Write the nodes *session* recorded (not the ones it adopted) beside the cell's entry *key*.""" namespace = session.graph.namespace payload = session.graph.to_dict() @@ -38,14 +47,14 @@ def save(directory: Path, cell_id: str, session: Any) -> None: "frames": session.frame_graph.to_dict(), } directory.mkdir(parents=True, exist_ok=True) - target = directory / f"{_PREFIX}{cell_id}" + target = _record(directory, cell_id, key) partial = target.with_suffix(".part") partial.write_bytes(zlib.compress(pickle.dumps(document, protocol=5), 1)) partial.replace(target) -def load(directory: Path, cell_id: str, graph_id: str | None = None) -> bool: - """Register the nodes saved for the cell; ``False`` when there is no entry. +def load(directory: Path, cell_id: str, graph_id: str | None = None, key: str | None = None) -> bool: + """Register the nodes saved for the cell's entry *key*; ``False`` when there is no record. With *graph_id*, a record of another graph is no entry either: its nodes would be registered under that graph, and the values restored beside it (which name their nodes by this run's graph) would find none. @@ -53,7 +62,7 @@ def load(directory: Path, cell_id: str, graph_id: str | None = None) -> bool: import simplecadapi._cpp as native - target = directory / f"{_PREFIX}{cell_id}" + target = _record(directory, cell_id, key) try: document = pickle.loads(zlib.decompress(target.read_bytes())) except OSError: diff --git a/test/test_cache_lineage_entries.py b/test/test_cache_lineage_entries.py new file mode 100644 index 00000000..455e07bf --- /dev/null +++ b/test/test_cache_lineage_entries.py @@ -0,0 +1,78 @@ +"""A cell's nodes are restored by the cache entry the values came from, not by the cell. + +A cell has one cache entry per combination of the values it reads (an assembly that ``use`` s a part with other +overrides runs the part's cells under the same graph id, digest and cell, with other inputs). The ids of the nodes +count from the cell's code, so two entries carry the same ids with other contents. The nodes were written beside the +cell, so the entry run last replaced the record of the others: restoring another entry then registered nodes that +did not belong to its values, and a later cell that read them failed with "not owned by active graph" (the ids the +other run did not have) or bound to the other run's node of the same id (the ids it had). +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +import pytest + +pytest.importorskip("marimo") + +import simplecadapi as scad +import simplecadapi._cpp as native +from simplecadapi.runtime import runner +from test_notebook_runtime import cell, notebook_text + +PART = notebook_text( + cell("LINK_L = 60.0", returns="LINK_L"), + cell("STEPS = 4", returns="STEPS"), + cell( + """ + beam = scad.make_box_rsolid(LINK_L, 10.0, 5.0) + for _i in range(STEPS): + beam = scad.union_rsolid(beam, scad.make_cylinder_rsolid(2.0, 8.0)) + """, + reads="LINK_L, STEPS", + returns="beam", + ), + cell('link = scad.Part(part_id="link", body=beam)', reads="beam", returns="link"), + table='id = "link"', +) + + +def cut_like_a_cell(beam): + """What the next cell of the notebook does with the value (a cell session that accepts other cells' nodes).""" + + with scad.GraphSession(graph_id="link", namespace="cCUT", shared_lineage=True) as session: + scad.cut_rsolid(beam, scad.make_cylinder_rsolid(1.0, 20.0)) + return session.graph + + +class EntryRecordTest(unittest.TestCase): + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.path = Path(tmp.name).resolve() / "link.py" + self.path.write_text(PART, encoding="utf-8") + native.forget_graph_nodes("link") + self.addCleanup(native.forget_graph_nodes, "link") + + def after_another_run(self, **overrides): + """The editor's own values, restored from the disk after a child run with *overrides* wrote its entries.""" + + runner.run_notebook(self.path) + runner.run_notebook(self.path, overrides=overrides) + native.forget_graph_nodes("link") # a new process: only the records of the disk register nodes + run = runner.run_notebook(self.path) + self.assertEqual({report.status for report in run.cells[1:]}, {"cached"}) + return run.values["beam"] + + def test_values_restored_after_a_run_with_fewer_nodes_find_their_nodes(self) -> None: + graph = cut_like_a_cell(self.after_another_run(STEPS=1)) + self.assertIn("make_union_rsolid", {node.op for node in graph.nodes}) + self.assertEqual(sum(node.op == "make_union_rsolid" for node in graph.nodes), 4) + + def test_values_restored_after_a_run_with_the_same_nodes_bind_to_their_own(self) -> None: + graph = cut_like_a_cell(self.after_another_run(LINK_L=90.0)) + boxes = [node for node in graph.nodes if node.op == "make_box_rsolid"] + self.assertEqual([box.params["width"] for box in boxes], [60.0]) From 3f18315e69e35254dd5111bcfba45e4f177ea3ce Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Mon, 2 Nov 2026 16:58:37 +0800 Subject: [PATCH 004/131] fix(transform): a rigid transform_rsolid moves the solid exactly instead of re-approximating it transform_rsolid always used GTransform, which rebuilds surfaces/curves as B-splines; for bodies built from B-spline extrusions, booleans and fillets (arm 'upper', 'wrist') that failed BRepCheck although the source was valid. An orthonormal det>0 matrix now uses gp_Trsf + BRepBuilderAPI_Transform; the general path stays for non-rigid matrices. Co-Authored-By: Claude Sonnet 5.5 --- src/cpp/src/ops/features/affine.cpp | 32 +++++++++++++++++++++++++++-- test/test_deformation_features.py | 21 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/cpp/src/ops/features/affine.cpp b/src/cpp/src/ops/features/affine.cpp index fcb4e66d..0c4b3562 100644 --- a/src/cpp/src/ops/features/affine.cpp +++ b/src/cpp/src/ops/features/affine.cpp @@ -77,6 +77,32 @@ TopoDS_Shape general_transform(const TopoDS_Shape& solid, const Mat3& linear, co }); } +// A rigid motion (orthonormal linear part, determinant +1) is exact in the kernel's Trsf form, which +// moves the geometry by location/transformation and keeps every surface, curve and p-curve as built. +// GTransform would rebuild them as approximated B-splines, which can lose BRepCheck validity. +bool is_rigid(const Mat3& linear) { + const Mat3 gram = multiply(linear, transposed(linear)); + for (std::size_t i = 0; i < 3; ++i) + for (std::size_t j = 0; j < 3; ++j) + if (std::abs(gram[i][j] - (i == j ? 1.0 : 0.0)) > 1e-9) + return false; + return determinant(linear) > 0; +} + +TopoDS_Shape rigid_transform(const TopoDS_Shape& solid, const Mat3& linear, const Vec3& translation, + const char* failure) { + return occt_guard([&]() -> TopoDS_Shape { + gp_Trsf transform; + transform.SetValues(linear[0][0], linear[0][1], linear[0][2], translation.x, linear[1][0], linear[1][1], + linear[1][2], translation.y, linear[2][0], linear[2][1], linear[2][2], translation.z); + BRepBuilderAPI_Transform builder(solid, transform, true); + builder.Build(); + if (!builder.IsDone()) + throw ValueError(failure); + return builder.Shape(); + }); +} + } // namespace Solid scale_rsolid(const Solid& solid, const Scalar3& factors, const Scalar3& center) { @@ -142,8 +168,10 @@ Solid transform_rsolid(const Solid& solid, const std::vector const Vec3 origin = features_detail::place_point({0, 0, 0}); const Mat3 linear = multiply(multiply(basis, affine), transposed(basis)); const Vec3 offset = apply(basis, {values[0][3], values[1][3], values[2][3]}); - const TopoDS_Shape transformed = general_transform(solid.wrapped(), linear, origin + offset - apply(linear, origin), - "kernel could not transform the solid"); + const Vec3 translation = origin + offset - apply(linear, origin); + const TopoDS_Shape transformed = + is_rigid(linear) ? rigid_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid") + : general_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid"); rec.input(solid, "body"); return finish_solid(solid, transformed, std::move(rec).take()); } diff --git a/test/test_deformation_features.py b/test/test_deformation_features.py index 88088d40..abe3ed80 100644 --- a/test/test_deformation_features.py +++ b/test/test_deformation_features.py @@ -95,3 +95,24 @@ def test_set_boolean_compound_members_and_graph_replay(operation): def test_set_boolean_complete_removal_returns_no_solid(): base = scad.make_box_rsolid(2, 2, 2) assert scad.boolean_rsolidlist(left=[base], right=[base], operation="cut") == [] + + +def _placed_matrix(angle_degrees, origin): + a = math.radians(angle_degrees) + c, s = math.cos(a), math.sin(a) + # A world placement: rotation about y composed with a quarter turn about x, then a translation. + return [[-c, -s, 0, origin[0]], [0, 0, -1, origin[1]], [s, -c, 0, origin[2]], [0, 0, 0, 1]] + + +@pytest.mark.parametrize("angle", [0.0, 25.0, 65.0]) +def test_rigid_transform_of_a_valid_solid_is_never_rejected(angle): + # A rigid motion must be exact: it may not re-approximate the surfaces (the general path turns + # them into B-splines and a body built from booleans and fillets can stop being valid). + box = scad.make_box_rsolid(width=30, height=18, depth=12) + hole = scad.translate_shape(scad.make_cylinder_rsolid(radius=3.2, height=40), (4, 2, -10)) + body = scad.cut_rsolid(box, hole) + edges = scad.ql.edges().where(scad.ql.prop("geom.length", ">", 11)).resolve(body) + body = scad.fillet_rsolid(body, list(edges), 1.5) + moved = scad.transform_rsolid(solid=body, matrix=_placed_matrix(angle, (0, -18, 91))) + assert moved.get_volume() == pytest.approx(body.get_volume(), rel=1e-9) + assert len(scad.ql.faces().resolve(moved)) == len(scad.ql.faces().resolve(body)) From 093ef71e539ec7ff4cad8d522b0cdd34850f01e5 Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Mon, 2 Nov 2026 20:52:03 +0800 Subject: [PATCH 005/131] style: clang-format the rigid-transform branch Co-Authored-By: Claude Sonnet 5.5 --- src/cpp/src/ops/features/affine.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cpp/src/ops/features/affine.cpp b/src/cpp/src/ops/features/affine.cpp index 0c4b3562..fbc0ad4a 100644 --- a/src/cpp/src/ops/features/affine.cpp +++ b/src/cpp/src/ops/features/affine.cpp @@ -170,8 +170,9 @@ Solid transform_rsolid(const Solid& solid, const std::vector const Vec3 offset = apply(basis, {values[0][3], values[1][3], values[2][3]}); const Vec3 translation = origin + offset - apply(linear, origin); const TopoDS_Shape transformed = - is_rigid(linear) ? rigid_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid") - : general_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid"); + is_rigid(linear) + ? rigid_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid") + : general_transform(solid.wrapped(), linear, translation, "kernel could not transform the solid"); rec.input(solid, "body"); return finish_solid(solid, transformed, std::move(rec).take()); } From a8a84c65c6d813cd261ede4b6eb2e0c3244f25ad Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:20:18 +0800 Subject: [PATCH 006/131] editor: GUI-made features, a typed operation registry and the commands over it features.py is a hand-curated table of 24 operations (primitives, profiles, booleans, hole, fillet, chamfer, shell, patterns, transforms) with typed slots and a template that renders the cells; a drift test checks every template against the live signatures. gui_features.py adds, edits and deletes features through the editor's own cell commands (an edit carries the code it was made from, so an agent's concurrent edit is never overwritten) and keeps an undo/redo history. Routes: GET /api/features/specs, GET /api/features, POST /api/features. Generated cells are ordinary cells (params cell + feature cell, selectors inline, a '# gui:' line to rebuild the form). Primitives and extrudes carry readable face tags; a selection recipe tries the tags first and checks the count. Co-Authored-By: Claude Sonnet 5.5 --- src/simplecadapi/editor/features.py | 764 ++++++++++++++++++++++++ src/simplecadapi/editor/gui_features.py | 387 ++++++++++++ src/simplecadapi/editor/selection.py | 17 +- src/simplecadapi/editor/server.py | 42 ++ test/test_editor_features.py | 259 ++++++++ 5 files changed, 1463 insertions(+), 6 deletions(-) create mode 100644 src/simplecadapi/editor/features.py create mode 100644 src/simplecadapi/editor/gui_features.py create mode 100644 test/test_editor_features.py diff --git a/src/simplecadapi/editor/features.py b/src/simplecadapi/editor/features.py new file mode 100644 index 00000000..7fc1bde8 --- /dev/null +++ b/src/simplecadapi/editor/features.py @@ -0,0 +1,764 @@ +"""GUI-made features: the operations a person can add with a button, and the cells they become. + +A feature is an ordinary notebook cell (``# ---- feature: () ----``, one operation that makes one new public +name) with its numbers in a ``# ---- params: ----`` cell, so a person or an agent can read and edit it like any +other cell. A cell made here carries one more line, ``# gui: {...}``, from which the form is rebuilt; a cell without that +line is code, shown in the tree and never rewritten. + +Nothing in this module runs a kernel or touches a file. :func:`render_feature` turns validated values into source text; +every value that reaches the text is a number, a choice from a fixed list, a name from the notebook, or a tag built from +the slug, so a request cannot put code into a cell. + +Selections are written as *recipes* inside the feature cell, resolved against the body the cell receives: the face tags a +primitive or extrude was given (they survive booleans, not fillets) first, the picked geometry second, and the count of +what was picked is always checked. A recipe that no longer resolves is an error on the feature, never another face. +""" + +from __future__ import annotations + +import builtins +import json +import keyword +import math +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Mapping, Sequence + +from .selection import selector_conditions + +GUI_MARKER = "# gui:" +VERSION = 1 +_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_SLUG = re.compile(r"[a-z][a-z0-9_]{0,40}") +_TAG = re.compile(r"[a-z][a-z0-9_]*(\.[a-z0-9_]+)+") +_RESERVED = frozenset(keyword.kwlist) | frozenset(dir(builtins)) | {"scad", "app", "marimo"} +_AXES = { + "+x": (1.0, 0.0, 0.0), + "-x": (-1.0, 0.0, 0.0), + "+y": (0.0, 1.0, 0.0), + "-y": (0.0, -1.0, 0.0), + "+z": (0.0, 0.0, 1.0), + "-z": (0.0, 0.0, -1.0), +} +_AXIS_OPTIONS = [{"value": key, "label": key} for key in _AXES] +_PLANE_NORMALS = {"XY": (0.0, 0.0, 1.0), "XZ": (0.0, 1.0, 0.0), "YZ": (1.0, 0.0, 0.0)} +_PLANE_OPTIONS = [{"value": key, "label": key} for key in _PLANE_NORMALS] + + +class FeatureError(ValueError): + """A request the registry cannot turn into a feature; the message is meant for the person.""" + + +@dataclass(frozen=True) +class Slot: + """One input of a feature. + + kind: ``length`` (mm), ``angle`` (deg), ``float``, ``count``, ``bool``, ``enum``, ``point`` (three lengths), + ``body`` (a body made earlier), ``select`` (faces or edges picked in the viewport), ``points2d`` (``x, y`` per line). + """ + + name: str + label: str + kind: str + default: Any = None + unit: str | None = None + min: float | None = None + max: float | None = None + step: float | None = None + options: list[dict[str, str]] | None = None + entity: str | None = None # face | edge, for kind == "select" + help: str = "" + optional: bool = False + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {"name": self.name, "label": self.label, "kind": self.kind} + for key in ("default", "unit", "min", "max", "step", "options", "entity"): + value = getattr(self, key) + if value is not None: + out[key] = value + if self.help: + out["help"] = self.help + if self.optional: + out["optional"] = True + return out + + +@dataclass(frozen=True) +class Spec: + id: str + label: str + group: str + role: str # build | add | subtract | modifier + produces: str # solid | solids + description: str + slots: tuple[Slot, ...] + build: Callable[["Context"], list[str]] = field(repr=False, compare=False) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "label": self.label, + "group": self.group, + "role": self.role, + "produces": self.produces, + "description": self.description, + "slots": [slot.to_dict() for slot in self.slots], + } + + +@dataclass +class Rendered: + slug: str + spec: str + params: list[str] # lines of the params cell ("" when the feature has no numbers) + feature: str # the feature cell + reads: list[str] # bodies this feature consumes + produces: str + gui: dict[str, Any] + + +class Context: + """What a template needs: the slug, the variable of each number, the bodies, the selection recipes.""" + + def __init__(self, spec: Spec, slug: str, values: Mapping[str, Any], bodies: Mapping[str, str], recipes: Mapping[str, list[str]]): + self.spec, self.slug, self.values = spec, slug, values + self._bodies, self._recipes = bodies, recipes + self.params: list[str] = [] + self.reads: list[str] = [] + self._base = slug.upper() + self.used: set[str] = set() + + # -- numbers -------------------------------------------------------------------------------------------------- + def var(self, slot: str) -> str: + """The params-cell variable holding this number (written on first use).""" + spec = self._slot(slot) + name = f"{self._base}_{slot.upper()}" + if name in self.used: + return name + self.used.add(name) + value = self.values[slot] + comment = spec.label + if spec.kind == "bool": + self.params.append(f"{name} = {bool(value)} # ui: toggle()") + elif spec.kind == "count": + low, high = _count_range(spec, int(value)) + self.params.append(f"{name} = {int(value)} # ui: number({low}, {high}, step=1, label={json.dumps(comment)})") + elif spec.kind == "float": + low, high = _range(spec, float(value)) + self.params.append(f"{name} = {_num(float(value))} # ui: number({_num(low)}, {_num(high)}, step={_num(_step(spec, low, high))}, label={json.dumps(comment)})") + else: + unit = "deg" if spec.kind == "angle" else "mm" + low, high = _range(spec, float(value)) + var_name = f"{self.slug}_{slot}" + self.params.append( + f"{name} = scad.var(name={json.dumps(var_name)}, default={_num(float(value))}, unit={json.dumps(unit)}, " + f"comment={json.dumps(comment)}) # ui: slider({_num(low)}, {_num(high)}, step={_num(_step(spec, low, high))}, label={json.dumps(comment)})" + ) + return name + + def point(self, slot: str) -> str: + """A ``(x, y, z)`` expression from the three length variables of a point slot.""" + spec = self._slot(slot) + names = [] + for axis, value in zip("xyz", self.values[slot]): + name = f"{self._base}_{slot.upper()}_{axis.upper()}" + if name not in self.used: + self.used.add(name) + low, high = _range(spec, float(value)) + self.params.append( + f"{name} = scad.var(name={json.dumps(f'{self.slug}_{slot}_{axis}')}, default={_num(float(value))}, unit=\"mm\", " + f"comment={json.dumps(f'{spec.label} {axis}')}) # ui: slider({_num(low)}, {_num(high)}, step=1, label={json.dumps(f'{spec.label} {axis}')})" + ) + names.append(name) + return f"({', '.join(names)})" + + def choice(self, slot: str) -> str: + """The value of an enum slot (already checked against its options).""" + return str(self.values[slot]) + + def axis(self, slot: str) -> str: + return repr(_AXES[str(self.values[slot])]) + + def normal(self, slot: str) -> str: + return repr(_PLANE_NORMALS[str(self.values[slot])]) + + # -- bodies and selections ------------------------------------------------------------------------------------ + def body(self, slot: str) -> str: + name = str(self.values[slot]) + if name not in self.reads: + self.reads.append(name) + return name + + def spread(self, slot: str) -> str: + """``*name`` when the body slot names a list of solids, else ``name``.""" + name = self.body(slot) + return f"*{name}" if self._bodies.get(name) == "solids" else name + + def selection(self, slot: str) -> str: + """The variable the recipe of this selection slot binds (the recipe is added to the cell first).""" + return f"_{slot}" + + def recipe(self, slot: str, tip: str) -> list[str]: + return list(self._recipes[slot]) + + def _slot(self, name: str) -> Slot: + for slot in self.spec.slots: + if slot.name == name: + return slot + raise KeyError(name) + + def tag(self, role: str) -> str: + """A readable face tag for this feature, e.g. ``base.top`` (survives booleans, not fillets).""" + return json.dumps(f"{self.slug}.{role}") + + +# ---- helpers ------------------------------------------------------------------------------------------------------ + + +def _num(value: float) -> str: + text = f"{value:.6g}" + if "e" in text or "E" in text: + text = f"{value:.9f}".rstrip("0").rstrip(".") + if "." not in text and "e" not in text: + text += ".0" + return "0.0" if text in ("-0.0", "") else text + + +def _range(spec: Slot, value: float) -> tuple[float, float]: + """Slider limits for a number: from the slot, else around the value.""" + if spec.min is None: + low, high = 0.1, max(value * 5, 10.0) + elif spec.min <= -1000: # a coordinate or a signed distance: a window around the value + low, high = math.floor(value - 100), math.ceil(value + 100) + else: + low = spec.min + high = spec.max if spec.max is not None else max(value * 5, low + 10.0) + if spec.max is not None and spec.min is not None and spec.min > -1000: + high = spec.max + if spec.kind == "angle" and spec.max is None: + high = 360.0 + return (low, high) if low < high else (low, low + 1.0) + + +def _count_range(spec: Slot, value: int) -> tuple[int, int]: + low = int(spec.min) if spec.min is not None else 1 + high = int(spec.max) if spec.max is not None else max(value * 4, 10) + return low, max(high, low + 1) + + +def _step(spec: Slot, low: float, high: float) -> float: + if spec.step is not None: + return spec.step + return 1.0 if high - low >= 20 else 0.1 + + +def _checked_number(slot: Slot, value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise FeatureError(f"{slot.label}: a number is required") + number = float(value) + if slot.kind == "length" and slot.min is None and number <= 0: + raise FeatureError(f"{slot.label}: must be greater than zero") + if slot.min is not None and number < slot.min: + raise FeatureError(f"{slot.label}: must be at least {slot.min:g}") + if slot.max is not None and number > slot.max: + raise FeatureError(f"{slot.label}: must be at most {slot.max:g}") + return number + + +def _length(name: str, label: str, default: float, **kw: Any) -> Slot: + return Slot(name, label, "length", default=default, unit="mm", **kw) + + +def _signed(name: str, label: str, default: float, **kw: Any) -> Slot: + return Slot(name, label, "length", default=default, unit="mm", min=kw.pop("min", -10000.0), **kw) + + +def _point(name: str, label: str, default: tuple[float, float, float] = (0.0, 0.0, 0.0), help: str = "") -> Slot: + return Slot(name, label, "point", default=list(default), unit="mm", min=-10000.0, help=help) + + +def _enum(name: str, label: str, options: list[dict[str, str]], default: str, help: str = "") -> Slot: + return Slot(name, label, "enum", default=default, options=options, help=help) + + +def _body(name: str, label: str, help: str = "") -> Slot: + return Slot(name, label, "body", help=help) + + +# ---- templates ---------------------------------------------------------------------------------------------------- + + +def _box(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_box_rsolid(", + f" width={c.var('width')}, height={c.var('height')}, depth={c.var('depth')},", + f" bottom_face_center={c.point('origin')},", + f" bottom_face_tag={c.tag('bottom')}, top_face_tag={c.tag('top')},", + f" front_face_tag={c.tag('front')}, back_face_tag={c.tag('back')},", + f" left_face_tag={c.tag('left')}, right_face_tag={c.tag('right')},", + ")", + ] + + +def _cylinder(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_cylinder_rsolid(", + f" radius={c.var('radius')}, height={c.var('height')},", + f" bottom_face_center={c.point('origin')}, axis={c.axis('axis')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_face_tag={c.tag('side')},", + ")", + ] + + +def _sphere(c: Context) -> list[str]: + return [f"{c.slug} = scad.make_sphere_rsolid(radius={c.var('radius')}, center={c.point('center')})"] + + +def _cone(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.make_cone_rsolid(", + f" bottom_radius={c.var('bottom_radius')}, top_radius={c.var('top_radius')}, height={c.var('height')},", + f" bottom_face_center={c.point('origin')}, axis={c.axis('axis')},", + # a true cone has no top face to name; the tag is written only when there is one + f" start_face_tag={c.tag('start')}, " + (f"end_face_tag={c.tag('end')}, " if c.values["top_radius"] > 0 else "") + f"side_face_tag={c.tag('side')},", + ")", + ] + + +def _extrude_rect(c: Context) -> list[str]: + return [ + f"_profile = scad.make_rectangle_rface(width={c.var('width')}, height={c.var('height')}, center={c.point('center')}, normal={c.normal('plane')})", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction={c.normal('plane')}, distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _extrude_circle(c: Context) -> list[str]: + return [ + f"_profile = scad.make_circle_rface(center={c.point('center')}, radius={c.var('radius')}, normal={c.normal('plane')})", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction={c.normal('plane')}, distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _points(values: Mapping[str, Any]) -> list[tuple[float, float]]: + return [(float(x), float(y)) for x, y in values["points"]] + + +def _extrude_polygon(c: Context) -> list[str]: + pts = ", ".join(f"({_num(x)}, {_num(y)}, 0.0)" for x, y in _points(c.values)) + return [ + f"_outline = scad.make_polyline_rwire(points=[{pts}], closed=True)", + "_profile = scad.make_face_from_wire_rface(wire=_outline, normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.extrude_rsolid(", + f" profile=_profile, direction=(0.0, 0.0, 1.0), distance={c.var('distance')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')},", + ")", + ] + + +def _revolve(c: Context) -> list[str]: + return [ + "# the section sits in the XZ plane, beside the Z axis, and turns about it", + f"_profile = scad.make_rectangle_rface(width={c.var('width')}, height={c.var('height')},", + f" center=({c.var('offset')} + {c.var('width')} / 2, 0.0, {c.var('height')} / 2), normal=(0.0, 1.0, 0.0))", + f"{c.slug} = scad.revolve_rsolid(profile=_profile, axis=(0.0, 0.0, 1.0), angle={c.var('angle')}, origin=(0.0, 0.0, 0.0),", + # a full turn has no start or end face to name; those tags are written only for a partial turn + " " + (f"start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, " if c.values["angle"] < 360 else "") + f"side_faces_tag={c.tag('side')})", + ] + + +def _loft_circles(c: Context) -> list[str]: + return [ + "_bottom = scad.make_circle_rwire(center=(0.0, 0.0, 0.0), radius=" + c.var("bottom_radius") + ", normal=(0.0, 0.0, 1.0))", + "_top = scad.make_circle_rwire(center=(0.0, 0.0, " + c.var("height") + "), radius=" + c.var("top_radius") + ", normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.loft_rsolid(profiles=[_bottom, _top], ruled={c.var('ruled')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _loft_rects(c: Context) -> list[str]: + return [ + f"_bottom = scad.make_rectangle_rwire(width={c.var('bottom_width')}, height={c.var('bottom_height')}, center=(0.0, 0.0, 0.0), normal=(0.0, 0.0, 1.0))", + f"_top = scad.make_rectangle_rwire(width={c.var('top_width')}, height={c.var('top_height')}, center=(0.0, 0.0, {c.var('height')}), normal=(0.0, 0.0, 1.0))", + f"{c.slug} = scad.loft_rsolid(profiles=[_bottom, _top], ruled={c.var('ruled')},", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _sweep(c: Context) -> list[str]: + pts = [(float(x), float(y), float(z)) for x, y, z in c.values["path"]] + path = ", ".join(f"({_num(x)}, {_num(y)}, {_num(z)})" for x, y, z in pts) + return [ + f"_path = scad.make_polyline_rwire(points=[{path}], closed=False)", + "# the circle stands on the first point, square to the first segment", + f"_a, _b = {pts[0]!r}, {pts[1]!r}", + "_dir = tuple(_q - _p for _p, _q in zip(_a, _b))", + "_len = sum(_d * _d for _d in _dir) ** 0.5", + "_dir = tuple(_d / _len for _d in _dir)", + f"_profile = scad.make_circle_rface(center=_a, radius={c.var('radius')}, normal=_dir)", + f"{c.slug} = scad.sweep_rsolid(profile=_profile, path=_path, is_frenet=False,", + f" start_face_tag={c.tag('start')}, end_face_tag={c.tag('end')}, side_faces_tag={c.tag('side')})", + ] + + +def _cut(c: Context) -> list[str]: + return [f"{c.slug} = scad.cut_rsolid({c.body('base')}, {c.spread('tool')}, skip_non_intersecting=False)"] + + +def _union(c: Context) -> list[str]: + return [f"{c.slug} = scad.union_rsolid({c.body('base')}, {c.spread('other')})"] + + +def _intersect(c: Context) -> list[str]: + return [f"{c.slug} = scad.intersect_rsolid({c.body('base')}, {c.spread('other')})"] + + +def _hole(c: Context) -> list[str]: + direction = c.axis("direction") + return [ + "# a cylinder from just outside the surface, in the drilling direction, taken out of the body", + f"_dir = {direction}", + "_overshoot = 1.0", + f"_start = tuple(_p - _overshoot * _d for _p, _d in zip({c.point('position')}, _dir))", + f"_tool = scad.make_cylinder_rsolid(radius={c.var('diameter')} / 2, height={c.var('depth')} + _overshoot, bottom_face_center=_start, axis=_dir)", + f"{c.slug} = scad.cut_rsolid({c.body('body')}, _tool, skip_non_intersecting=False)", + ] + + +def _fillet(c: Context) -> list[str]: + return [*c.recipe("edges", "tip"), f"{c.slug} = scad.fillet_rsolid(solid={c.body('body')}, edges={c.selection('edges')}, radius={c.var('radius')})"] + + +def _chamfer(c: Context) -> list[str]: + return [*c.recipe("edges", "tip"), f"{c.slug} = scad.chamfer_rsolid(solid={c.body('body')}, edges={c.selection('edges')}, distance={c.var('distance')})"] + + +def _shell(c: Context) -> list[str]: + return [ + *c.recipe("faces", "tip"), + f"{c.slug} = scad.shell_rsolid(solid={c.body('body')}, faces_to_remove={c.selection('faces')}, thickness={c.var('thickness')})", + ] + + +def _linear_pattern(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.linear_pattern_rsolidlist(shape={c.body('shape')}, direction={c.axis('direction')}, count={c.var('count')}, spacing={c.var('spacing')})" + ] + + +def _radial_pattern(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.radial_pattern_rsolidlist(shape={c.body('shape')}, center={c.point('center')}, axis={c.axis('axis')},", + f" count={c.var('count')}, total_rotation_angle={c.var('total_angle')})", + ] + + +def _mirror(c: Context) -> list[str]: + return [ + f"_mirrored = scad.mirror_shape(shape={c.body('body')}, plane_origin={c.point('origin')}, plane_normal={c.axis('normal')})", + f"{c.slug} = scad.union_rsolid({c.body('body')}, _mirrored)" if c.values.get("merge") else f"{c.slug} = _mirrored", + ] + + +def _translate(c: Context) -> list[str]: + return [f"{c.slug} = scad.translate_shape(shape={c.body('body')}, vector={c.point('offset')})"] + + +def _rotate(c: Context) -> list[str]: + return [ + f"{c.slug} = scad.rotate_shape(shape={c.body('body')}, angle={c.var('angle')}, axis={c.axis('axis')}, origin={c.point('origin')})" + ] + + +def _scale(c: Context) -> list[str]: + factor = c.var("factor") + return [f"{c.slug} = scad.scale_rsolid(solid={c.body('body')}, factors=({factor}, {factor}, {factor}), center={c.point('origin')})"] + + +_AXIS = lambda name, label, default="+z": _enum(name, label, _AXIS_OPTIONS, default) # noqa: E731 +_PLANE = lambda: _enum("plane", "Sketch plane", _PLANE_OPTIONS, "XY", help="The profile lies in this plane and is pushed along its normal.") # noqa: E731 +_BODY = lambda: _body("body", "Body", "The body this feature works on (default: the last one made).") # noqa: E731 + +SPECS: tuple[Spec, ...] = ( + Spec("box", "Box", "Primitives", "build", "solid", "A rectangular block; its bottom face is centred on the origin point.", + (_length("width", "Width (x)", 40.0), _length("height", "Height (y)", 30.0), _length("depth", "Depth (z)", 20.0), + _point("origin", "Bottom face centre")), _box), + Spec("cylinder", "Cylinder", "Primitives", "build", "solid", "A cylinder standing on a centre point, along an axis.", + (_length("radius", "Radius", 10.0), _length("height", "Height", 30.0), _point("origin", "Bottom face centre"), + _AXIS("axis", "Axis")), _cylinder), + Spec("sphere", "Sphere", "Primitives", "build", "solid", "A sphere around a centre point.", + (_length("radius", "Radius", 15.0), _point("center", "Centre")), _sphere), + Spec("cone", "Cone / frustum", "Primitives", "build", "solid", "A cone, or a frustum when the top radius is above zero.", + (_length("bottom_radius", "Bottom radius", 15.0), Slot("top_radius", "Top radius", "length", default=0.0, unit="mm", min=0.0), + _length("height", "Height", 25.0), _point("origin", "Bottom face centre"), _AXIS("axis", "Axis")), _cone), + Spec("extrude_rect", "Extrude rectangle", "Profiles", "build", "solid", "A rectangle pushed along the normal of its plane.", + (_PLANE(), _length("width", "Width", 40.0), _length("height", "Height", 20.0), _point("center", "Centre"), _signed("distance", "Distance", 10.0, min=0.001)), _extrude_rect), + Spec("extrude_circle", "Extrude circle", "Profiles", "build", "solid", "A circle pushed along the normal of its plane.", + (_PLANE(), _length("radius", "Radius", 12.0), _point("center", "Centre"), _signed("distance", "Distance", 10.0, min=0.001)), _extrude_circle), + Spec("extrude_polygon", "Extrude polygon", "Profiles", "build", "solid", "A closed polygon in the XY plane pushed up the Z axis.", + (Slot("points", "Corners (x, y per line)", "points2d", default=[[0, 0], [40, 0], [40, 20], [20, 30], [0, 20]], unit="mm"), + _signed("distance", "Distance", 10.0, min=0.001)), _extrude_polygon), + Spec("revolve", "Revolve", "Profiles", "build", "solid", "A rectangle beside the Z axis, turned about it.", + (_length("width", "Section width", 10.0), _length("height", "Section height", 25.0), + Slot("offset", "Distance from the axis", "length", default=10.0, unit="mm", min=0.0), + Slot("angle", "Angle", "angle", default=360.0, unit="deg", min=1.0, max=360.0)), _revolve), + Spec("loft_circles", "Loft circles", "Profiles", "build", "solid", "A solid between two circles, one above the other.", + (_length("bottom_radius", "Bottom radius", 15.0), _length("top_radius", "Top radius", 8.0), _length("height", "Height", 25.0), + Slot("ruled", "Straight sides", "bool", default=False)), _loft_circles), + Spec("loft_rects", "Loft rectangles", "Profiles", "build", "solid", "A solid between two rectangles, one above the other.", + (_length("bottom_width", "Bottom width", 30.0), _length("bottom_height", "Bottom height", 20.0), + _length("top_width", "Top width", 15.0), _length("top_height", "Top height", 10.0), _length("height", "Height", 25.0), + Slot("ruled", "Straight sides", "bool", default=True)), _loft_rects), + Spec("sweep_circle", "Sweep circle along a path", "Profiles", "build", "solid", "A circle swept along a polyline.", + (_length("radius", "Radius", 4.0), Slot("path", "Path points (x, y, z per line)", "points3d", default=[[0, 0, 0], [0, 0, 30], [30, 0, 60]], unit="mm")), _sweep), + Spec("cut", "Cut", "Boolean", "subtract", "solid", "Take one or more bodies out of another.", + (_body("base", "Body to cut"), _body("tool", "Tool (taken out)")), _cut), + Spec("union", "Union", "Boolean", "add", "solid", "Join two bodies into one.", + (_body("base", "First body"), _body("other", "Second body")), _union), + Spec("intersect", "Intersect", "Boolean", "modifier", "solid", "Keep only what two bodies share.", + (_body("base", "First body"), _body("other", "Second body")), _intersect), + Spec("hole", "Hole", "Features", "subtract", "solid", "A round hole from a point on a surface, in a direction, to a depth.", + (_BODY(), _point("position", "Start point (on the surface)", help="Pick a face to fill the point and direction."), + _AXIS("direction", "Direction into the material", "-z"), _length("diameter", "Diameter", 6.0), _length("depth", "Depth", 10.0)), _hole), + Spec("fillet", "Fillet", "Features", "modifier", "solid", "Round the picked edges.", + (_BODY(), Slot("edges", "Edges", "select", entity="edge"), _length("radius", "Radius", 2.0)), _fillet), + Spec("chamfer", "Chamfer", "Features", "modifier", "solid", "Bevel the picked edges.", + (_BODY(), Slot("edges", "Edges", "select", entity="edge"), _length("distance", "Distance", 1.5)), _chamfer), + Spec("shell", "Shell", "Features", "modifier", "solid", "Hollow the body, opening the picked faces.", + (_BODY(), Slot("faces", "Faces to open", "select", entity="face"), _length("thickness", "Wall thickness", 2.0)), _shell), + Spec("linear_pattern", "Linear pattern", "Pattern", "build", "solids", "Copies of a body along a direction (the original is the first copy).", + (_body("shape", "Body to repeat"), _AXIS("direction", "Direction", "+x"), Slot("count", "Count", "count", default=3, min=2, max=500), + _length("spacing", "Spacing", 15.0)), _linear_pattern), + Spec("radial_pattern", "Radial pattern", "Pattern", "build", "solids", "Copies of a body around an axis.", + (_body("shape", "Body to repeat"), _point("center", "Axis point"), _AXIS("axis", "Axis"), + Slot("count", "Count", "count", default=6, min=2, max=500), + Slot("total_angle", "Total angle", "angle", default=360.0, unit="deg", min=1.0, max=360.0)), _radial_pattern), + Spec("mirror", "Mirror", "Transform", "build", "solid", "Mirror a body in a plane, and optionally join the halves.", + (_BODY(), _point("origin", "Plane point"), _AXIS("normal", "Plane normal", "+x"), Slot("merge", "Join with the original", "bool", default=True)), _mirror), + Spec("translate", "Move", "Transform", "build", "solid", "Move a body by an offset.", + (_BODY(), _point("offset", "Offset")), _translate), + Spec("rotate", "Rotate", "Transform", "build", "solid", "Turn a body about an axis.", + (_BODY(), Slot("angle", "Angle", "angle", default=45.0, unit="deg", min=-360.0, max=360.0), _AXIS("axis", "Axis"), _point("origin", "Axis point")), _rotate), + Spec("scale", "Scale", "Transform", "build", "solid", "Scale a body uniformly about a point.", + (_BODY(), Slot("factor", "Factor", "float", default=1.5, min=0.01, max=100.0), _point("origin", "Centre")), _scale), +) + +BY_ID: dict[str, Spec] = {spec.id: spec for spec in SPECS} + + +def catalog() -> dict[str, Any]: + """The registry as plain data, for the forms.""" + groups: list[str] = [] + for spec in SPECS: + if spec.group not in groups: + groups.append(spec.group) + return {"version": VERSION, "groups": [{"id": g, "label": g} for g in groups], "specs": [spec.to_dict() for spec in SPECS]} + + +# ---- names ---------------------------------------------------------------------------------------------------- + + +def default_slug(spec_id: str, taken: set[str]) -> str: + base = {"extrude_rect": "block", "extrude_circle": "disc", "extrude_polygon": "profile_solid"}.get(spec_id, spec_id) + candidate, number = base, 1 + while candidate in taken or candidate.upper() in taken or candidate in _RESERVED: + number += 1 + candidate = f"{base}_{number}" + return candidate + + +def check_slug(slug: str, taken: set[str]) -> str: + if not _SLUG.fullmatch(slug): + raise FeatureError("the name must be lowercase letters, digits and underscores, starting with a letter") + if slug in _RESERVED: + raise FeatureError(f"{slug!r} is a Python name; choose another") + if slug in taken or slug.upper() in taken: + raise FeatureError(f"the name {slug!r} is already used in this notebook") + return slug + + +# ---- values ----------------------------------------------------------------------------------------------------------- + + +def check_values(spec: Spec, values: Mapping[str, Any], bodies: Mapping[str, str]) -> dict[str, Any]: + """Validate *values* against the slots; returns the cleaned values. Raises :class:`FeatureError`.""" + clean: dict[str, Any] = {} + for slot in spec.slots: + raw = values.get(slot.name, slot.default) + if slot.kind == "select": + continue # the recipe is built from the picked items + if raw is None: + raise FeatureError(f"{slot.label}: a value is required") + if slot.kind in ("length", "angle", "float"): + clean[slot.name] = _checked_number(slot, raw) + elif slot.kind == "count": + number = _checked_number(slot, raw) + if number != int(number): + raise FeatureError(f"{slot.label}: a whole number is required") + clean[slot.name] = int(number) + elif slot.kind == "bool": + if not isinstance(raw, bool): + raise FeatureError(f"{slot.label}: true or false") + clean[slot.name] = raw + elif slot.kind == "enum": + allowed = {option["value"] for option in slot.options or []} + if raw not in allowed: + raise FeatureError(f"{slot.label}: choose one of {', '.join(sorted(allowed))}") + clean[slot.name] = str(raw) + elif slot.kind == "point": + if not (isinstance(raw, (list, tuple)) and len(raw) == 3): + raise FeatureError(f"{slot.label}: three numbers (x, y, z)") + clean[slot.name] = [_checked_number(Slot(slot.name, slot.label, "float", min=slot.min, max=slot.max), part) for part in raw] + elif slot.kind == "body": + name = str(raw) + if not _NAME.fullmatch(name) or name not in bodies: + raise FeatureError(f"{slot.label}: {name!r} is not a body in this notebook") + clean[slot.name] = name + elif slot.kind in ("points2d", "points3d"): + width = 2 if slot.kind == "points2d" else 3 + if not isinstance(raw, (list, tuple)) or len(raw) < width: + raise FeatureError(f"{slot.label}: at least {width} points") + rows = [] + for row in raw: + if not (isinstance(row, (list, tuple)) and len(row) == width): + raise FeatureError(f"{slot.label}: each point needs {width} numbers") + rows.append([_checked_number(Slot(slot.name, slot.label, "float"), part) for part in row]) + if slot.kind == "points2d" and len(rows) < 3: + raise FeatureError(f"{slot.label}: a polygon needs at least three corners") + if slot.kind == "points3d" and len(rows) < 2: + raise FeatureError(f"{slot.label}: a path needs at least two points") + clean[slot.name] = rows + else: # pragma: no cover - the registry is ours + raise FeatureError(f"{slot.label}: unsupported slot kind {slot.kind}") + return clean + + +# ---- selection recipes ---------------------------------------------------------------------------------------------- + + +def selection_recipe(slot: Slot, items: Sequence[Mapping[str, Any]], known_slugs: set[str]) -> list[str]: + """Source lines of a recipe that selects the picked *items* inside the feature cell (bound to ``_``).""" + factory = {"face": "faces", "edge": "edges"}[slot.entity or "face"] + picked = [item for item in items if item.get("kind") == slot.entity] + if not picked: + raise FeatureError(f"{slot.label}: pick at least one {slot.entity} in the viewport") + geometry: list[str] = [] + tags: list[str] = [] + all_tagged = True + for item in picked: + conditions = selector_conditions(str(item.get("kind")), item.get("properties") or {}, item.get("geometry") or {}) + if conditions is None: + raise FeatureError(f"{slot.label}: an item has no geometry to select it by") + geometry.append("scad.ql.and_(" + ", ".join(conditions) + ")") + own = [t for t in (item.get("tags") or []) if isinstance(t, str) and _TAG.fullmatch(t) and t.split(".")[0] in known_slugs] + if own: + tags.append(f"scad.ql.tag({json.dumps(sorted(own)[0])})") + else: + all_tagged = False + n = len(picked) + lines = [f"# {slot.label.lower()}: {n} picked in the viewport, resolved against the body this feature receives"] + wrap = (lambda parts: parts[0]) if n == 1 else (lambda parts: "scad.ql.or_(" + ", ".join(parts) + ")") + geo = f"scad.ql.{factory}().where({wrap(geometry)})" + if tags and all_tagged: + lines.append(f"_{slot.name}_by_tag = scad.ql.{factory}().where({wrap(tags)})") + lines.append(f"_{slot.name}_by_geometry = {geo}") + lines.append(f"_{slot.name}_source = {{BODY}}") + lines.append(f"_{slot.name} = _{slot.name}_by_tag if len(_{slot.name}_by_tag.resolve(_{slot.name}_source)) == {n} else _{slot.name}_by_geometry.exactly({n})") + else: + lines.append(f"_{slot.name} = {geo}.exactly({n})") + return lines + + +# ---- rendering ---------------------------------------------------------------------------------------------------------- + + +def extract_recipe(code: str, slot: str) -> list[str] | None: + """The recipe lines of a selection slot in an existing feature cell, so an edit can keep what was picked.""" + lines = code.splitlines() + start = next((i for i, line in enumerate(lines) if line.startswith("# ") and "picked in the viewport" in line), None) + if start is None: + return None + for end in range(start + 1, len(lines)): + if lines[end].startswith(f"_{slot} = "): + return [re.sub(r"(_\w+_source = ).*", r"\1{BODY}", line) for line in lines[start : end + 1]] + return None + + +def render_feature( + spec_id: str, + slug: str, + values: Mapping[str, Any], + *, + bodies: Mapping[str, str], + selections: Mapping[str, Sequence[Mapping[str, Any]]] | None = None, + known_slugs: set[str] | None = None, + keep_recipes: Mapping[str, list[str]] | None = None, +) -> Rendered: + """The cells of one feature. *bodies* maps each body variable of the notebook to ``solid`` or ``solids``.""" + spec = BY_ID.get(spec_id) + if spec is None: + raise FeatureError(f"unknown operation {spec_id!r}") + clean = check_values(spec, values, bodies) + recipes: dict[str, list[str]] = {} + for slot in spec.slots: + if slot.kind == "select": + if (selections or {}).get(slot.name): + recipes[slot.name] = selection_recipe(slot, selections[slot.name], known_slugs or set()) + elif keep_recipes and keep_recipes.get(slot.name): + recipes[slot.name] = list(keep_recipes[slot.name]) + else: + raise FeatureError(f"{slot.label}: pick at least one {slot.entity} in the viewport") + ctx = Context(spec, slug, clean, bodies, recipes) + code = spec.build(ctx) + # the recipe resolves against the body slot of the feature ({BODY} is filled with that name) + body_name = clean.get("body") or "" + code = [line.replace("{BODY}", body_name) for line in code] + gui = {"spec": spec.id, "v": VERSION, "values": clean} + if selections: + gui["picked"] = {k: len(v) for k, v in selections.items()} + header = f"# ---- feature: {slug} ({spec.role}) ----" + feature_lines = [header, f"{GUI_MARKER} {json.dumps(gui, separators=(',', ':'))}", *code] + params = [f"# ---- params: {slug} ----", *ctx.params] if ctx.params else [] + return Rendered( + slug=slug, + spec=spec.id, + params=params, + feature="\n".join(feature_lines), + reads=ctx.reads, + produces=spec.produces, + gui=gui, + ) + + +def parse_gui(code: str) -> dict[str, Any] | None: + """The ``# gui:`` payload of a feature cell, or ``None`` for a cell written as code.""" + for line in code.splitlines()[:6]: + stripped = line.strip() + if stripped.startswith(GUI_MARKER): + try: + data = json.loads(stripped[len(GUI_MARKER):]) + except ValueError: + return None + return data if isinstance(data, dict) and data.get("spec") in BY_ID else None + return None + + +__all__ = [ + "BY_ID", + "FeatureError", + "Rendered", + "SPECS", + "catalog", + "check_slug", + "check_values", + "default_slug", + "extract_recipe", + "parse_gui", + "render_feature", + "selection_recipe", +] diff --git a/src/simplecadapi/editor/gui_features.py b/src/simplecadapi/editor/gui_features.py new file mode 100644 index 00000000..e01ff47b --- /dev/null +++ b/src/simplecadapi/editor/gui_features.py @@ -0,0 +1,387 @@ +"""Adding, editing and removing GUI-made features in the open notebook, with undo. + +:mod:`.features` turns values into cells; this module finds where they go, writes them through the editor's own cell +commands (so the page shows them live, the file has one writer, and an agent editing the same notebook is never +overwritten: every edit carries the code it was made from), and keeps what is needed to undo. + +A feature is two cells: ``# ---- params: ----`` (its numbers) and ``# ---- feature: () ----``. The +product cell (the one that builds the ``Part`` from a body) is pointed at the new body when the feature consumed the body it +was reading; otherwise it is left alone and the state says which body it reads. +""" + +from __future__ import annotations + +import ast +import re +import threading +from typing import Any, Mapping + +from ..runtime.workspace import features_in_cell +from . import features as F + +_PARAMS_HEADER = re.compile(r"^#\s*-{3,}\s*params\b[^\n]*", re.MULTILINE) +_PARAMS_FOR = re.compile(r"^#\s*-{3,}\s*params:\s*(?P[a-z][a-z0-9_]*)\s*-{3,}\s*$") +_PRODUCT_BODY = re.compile(r"(\bbody\s*=\s*)([A-Za-z_][A-Za-z0-9_]*)") +_ROLES_WITH_BODIES = {"build", "add", "subtract", "modifier"} + + +class GuiError(Exception): + """A GUI command that cannot be done; ``conflict`` marks one that lost to a concurrent edit.""" + + def __init__(self, message: str, *, conflict: bool = False): + super().__init__(message) + self.conflict = conflict + + +def _names(code: str) -> tuple[list[str], set[str]]: + """(public names the cell defines at its top level, in order; every name it reads).""" + try: + tree = ast.parse(code) + except (SyntaxError, ValueError): + return [], set() + defined: list[str] = [] + for node in tree.body: + targets: list[ast.expr] = [] + if isinstance(node, ast.Assign): + targets = list(node.targets) + elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): + targets = [node.target] + for target in targets: + for name in ast.walk(target): + if isinstance(name, ast.Name) and not name.id.startswith("_") and name.id not in defined: + defined.append(name.id) + loaded = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} + return defined, loaded + + +class FeatureService: + """The GUI-feature view and commands of one :class:`~simplecadapi.editor.server.WorkspaceState`.""" + + def __init__(self, state: Any): + self.state = state + self._lock = threading.RLock() + self._undo: list[list[dict]] = [] + self._redo: list[list[dict]] = [] + self._notebook: Any = None + + # -- reading ---------------------------------------------------------------------------------------------------- + def snapshot(self) -> dict[str, Any]: + """Features, bodies, the product, and the defaults a form should open with.""" + cells = list(self.state.cells) + features: list[dict[str, Any]] = [] + bodies: dict[str, str] = {} + consumed: set[str] = set() + order: list[str] = [] + taken: set[str] = set() + product: dict[str, Any] | None = None + params_cells: dict[str, str] = {} + for index, cell in enumerate(cells): + code = str(cell.get("code", "")) + defined, loaded = _names(code) + taken.update(defined) + first = code.strip().splitlines()[0] if code.strip() else "" + match = _PARAMS_FOR.match(first) + if match: + params_cells[match.group("slug")] = cell["id"] + continue + heads = features_in_cell(code, cell.get("key", "")) + if not heads: + if ("Part(" in code or "make_part_rpart(" in code) and _PRODUCT_BODY.search(code): + product = {"cell": cell["id"], "body": _PRODUCT_BODY.search(code).group(2)} # type: ignore[union-attr] + continue + head = heads[0] + gui = F.parse_gui(code) + consumed.update(loaded & set(bodies)) + produces = F.BY_ID[gui["spec"]].produces if gui else "solid" + made = defined if head.role in _ROLES_WITH_BODIES else [] + for name in made: + bodies[name] = produces + order.append(name) + features.append( + { + "cell": cell["id"], + "index": index + 1, + "slug": head.slug, + "role": head.role, + "gui": {"spec": gui["spec"], "values": gui.get("values", {}), "picked": gui.get("picked", {})} if gui else None, + "makes": made, + "reads": sorted(loaded & set(bodies) - set(made)), + "status": cell.get("status"), + "errors": cell.get("errors") or [], + } + ) + for feature in features: + feature["params_cell"] = params_cells.get(feature["slug"]) + open_bodies = [name for name in order if name not in consumed] + return { + "features": features, + "bodies": [{"name": name, "produces": bodies[name]} for name in order], + "open": open_bodies, + "tip": open_bodies[-1] if open_bodies else None, + "product": product, + "defaults": self._defaults(open_bodies), + "taken": sorted(taken), + "undo": len(self._undo), + "redo": len(self._redo), + } + + @staticmethod + def _defaults(open_bodies: list[str]) -> dict[str, dict[str, str]]: + last = open_bodies[-1] if open_bodies else None + before = open_bodies[-2] if len(open_bodies) > 1 else last + out: dict[str, dict[str, str]] = {} + for spec in F.SPECS: + chosen: dict[str, str] = {} + for slot in spec.slots: + if slot.kind != "body" or last is None: + continue + chosen[slot.name] = before if slot.name == "base" else last + out[spec.id] = chosen + return out + + # -- commands ----------------------------------------------------------------------------------------------------- + def add(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + spec_id = str(request.get("spec", "")) + if spec_id not in F.BY_ID: + raise GuiError(f"unknown operation {spec_id!r}") + taken = set(snap["taken"]) + slug = F.check_slug(str(request["name"]), taken) if request.get("name") else F.default_slug(spec_id, taken) + bodies = {b["name"]: b["produces"] for b in snap["bodies"]} + values = self._with_defaults(spec_id, dict(request.get("values") or {}), snap) + rendered = self._render(spec_id, slug, values, bodies, request.get("selections"), snap) + self._check_param_names(rendered, taken) + steps: list[dict] = [] + cells = self.state.cells + params_anchor = self._params_anchor(cells) + feature_anchor = request.get("after") or self._feature_anchor(snap, cells) + if rendered.params: + steps.append(self._create(params_anchor, "\n".join(rendered.params), run=False)) + if feature_anchor == params_anchor: # the params cell sits where the feature goes: keep params first + feature_anchor = steps[-1]["id"] + made = self._create(feature_anchor, rendered.feature, run=True) + steps.append(made) + steps.extend(self._repoint_product(snap, rendered)) + self._record(steps) + return self._result(made["id"], steps, slug) + + def edit(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + feature = self._feature(snap, request.get("cell")) + if not feature["gui"]: + raise GuiError("this feature was written as code; edit it in the notebook") + cell = self._cell(feature["cell"]) + spec_id = feature["gui"]["spec"] + spec = F.BY_ID[spec_id] + values = {**feature["gui"]["values"], **dict(request.get("values") or {})} + bodies = {b["name"]: b["produces"] for b in snap["bodies"] if b["name"] != feature["slug"]} + keep = {slot.name: F.extract_recipe(cell["code"], slot.name) for slot in spec.slots if slot.kind == "select"} + rendered = self._render(spec_id, feature["slug"], values, bodies, request.get("selections"), snap, keep={k: v for k, v in keep.items() if v}) + steps: list[dict] = [] + if feature["params_cell"]: + old = self._cell(feature["params_cell"])["code"] + new = "\n".join(rendered.params) + if rendered.params and new.strip() != old.strip(): + steps.append(self._edit(feature["params_cell"], old, new, run=False)) + elif rendered.params: + steps.append(self._create(self._params_anchor(self.state.cells), "\n".join(rendered.params), run=False)) + steps.append(self._edit(feature["cell"], cell["code"], rendered.feature, run=True)) + self._record(steps) + return self._result(feature["cell"], steps, feature["slug"]) + + def delete(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + feature = self._feature(snap, request.get("cell")) + users = [f["slug"] for f in snap["features"] if f["cell"] != feature["cell"] and set(f["reads"]) & set(feature["makes"])] + if users: + raise GuiError(f"{', '.join(users)} still use {feature['slug']}; remove those first") + steps: list[dict] = [] + product = snap["product"] + if product and product["body"] in feature["makes"]: + fallback = feature["reads"][0] if feature["reads"] else None + if fallback is None: + raise GuiError(f"the product is built from {feature['slug']}; point it at another body first") + steps.append(self._point_product(product, fallback)) + for cell_id in (feature["cell"], feature["params_cell"]): + if cell_id: + steps.append(self._remove(cell_id)) + self._record(steps) + return self._result(None, steps, feature["slug"]) + + def use_as_product(self, request: Mapping[str, Any]) -> dict[str, Any]: + with self._lock: + self._sync() + snap = self.snapshot() + name = str(request.get("body", "")) + if name not in {b["name"] for b in snap["bodies"]}: + raise GuiError(f"{name!r} is not a body in this notebook") + if not snap["product"]: + raise GuiError("this notebook has no product cell that builds a Part from a body") + steps = [self._point_product(snap["product"], name)] + self._record(steps) + return self._result(snap["product"]["cell"], steps, name) + + def undo(self) -> dict[str, Any]: + with self._lock: + self._sync() + if not self._undo: + raise GuiError("nothing to undo") + steps = self._undo.pop() + for step in reversed(steps): + self._apply(step, forward=False) + self._redo.append(steps) + return self._result(None, [], "undo") + + def redo(self) -> dict[str, Any]: + with self._lock: + self._sync() + if not self._redo: + raise GuiError("nothing to redo") + steps = self._redo.pop() + for step in steps: + self._apply(step, forward=True) + self._undo.append(steps) + return self._result(None, [], "redo") + + # -- internals ------------------------------------------------------------------------------------------------------ + def _sync(self) -> None: + """History belongs to one open notebook.""" + current = getattr(self.state, "notebook", None) + if current != self._notebook: + self._notebook = current + self._undo, self._redo = [], [] + + def _record(self, steps: list[dict]) -> None: + if steps: + self._undo.append(steps) + self._redo.clear() + del self._undo[:-50] + + def _cell(self, ref: Any) -> dict: + for cell in self.state.cells: + if cell["id"] == ref: + return cell + raise GuiError(f"the cell {ref!r} is gone; reload the feature list") + + @staticmethod + def _feature(snap: Mapping[str, Any], ref: Any) -> dict[str, Any]: + for feature in snap["features"]: + if ref in (feature["cell"], feature["slug"]): + return feature + raise GuiError(f"no feature {ref!r}") + + @staticmethod + def _with_defaults(spec_id: str, values: dict[str, Any], snap: Mapping[str, Any]) -> dict[str, Any]: + for name, body in (snap["defaults"].get(spec_id) or {}).items(): + values.setdefault(name, body) + return values + + def _render(self, spec_id, slug, values, bodies, selections, snap, keep=None) -> F.Rendered: + known = {f["slug"] for f in snap["features"]} + picked = {k: list(v) for k, v in dict(selections or {}).items() if isinstance(v, (list, tuple))} + try: + return F.render_feature(spec_id, slug, values, bodies=bodies, selections=picked, known_slugs=known, keep_recipes=keep) + except F.FeatureError as exc: + raise GuiError(str(exc)) from exc + + @staticmethod + def _check_param_names(rendered: F.Rendered, taken: set[str]) -> None: + for line in rendered.params: + match = re.match(r"([A-Z][A-Z0-9_]*) = ", line) + if match and match.group(1) in taken: + raise GuiError(f"the name {match.group(1)} is already used in this notebook") + + @staticmethod + def _params_anchor(cells: list[dict]) -> str: + anchor = "start" + for cell in cells: + if _PARAMS_HEADER.match(str(cell.get("code", "")).strip()): + anchor = cell["id"] + return anchor + + @staticmethod + def _feature_anchor(snap: Mapping[str, Any], cells: list[dict]) -> str: + """After the last feature cell (never 'end': the selection cell must stay last).""" + if snap["features"]: + return snap["features"][-1]["cell"] + return FeatureService._params_anchor(cells) + + def _command(self, op: str, body: dict) -> dict: + report = self.state.cell_command(op, body) + if "error" in report: + message = str(report["error"]) + raise GuiError(message, conflict=message.startswith("conflict")) + return report + + def _create(self, anchor: str, code: str, *, run: bool) -> dict: + report = self._command("insert", {"after": anchor, "code": code, "run": run}) + index = (report.get("touched") or [None])[0] + cell = next((c for c in report["cells"] if c["index"] == index), None) + if cell is None: + raise GuiError("the kernel did not create the cell") + return {"kind": "created", "id": cell["id"], "after": anchor, "code": code} + + def _edit(self, cell_id: str, old: str, new: str, *, run: bool) -> dict: + self._command("edit", {"cell": cell_id, "code": new, "base": old, "run": run}) + return {"kind": "edited", "id": cell_id, "old": old, "new": new} + + def _remove(self, cell_id: str, expect: str | None = None) -> dict: + """Delete a cell; with *expect*, only if it still holds the code the GUI wrote (else it was edited since).""" + cells = self.state.cells + position = next((i for i, c in enumerate(cells) if c["id"] == cell_id), None) + if position is None: + raise GuiError("the cell is gone", conflict=True) + code = cells[position]["code"] + if expect is not None and code.strip() != expect.strip(): + raise GuiError(f"conflict: the cell {cell_id} was edited since", conflict=True) + before = cells[position - 1]["id"] if position else "start" + self._command("delete", {"cell": cell_id}) + return {"kind": "deleted", "id": cell_id, "after": before, "code": code} + + def _point_product(self, product: Mapping[str, Any], body: str) -> dict: + cell = self._cell(product["cell"]) + new = _PRODUCT_BODY.sub(lambda m: f"{m.group(1)}{body}", cell["code"], count=1) + return self._edit(product["cell"], cell["code"], new, run=True) + + def _repoint_product(self, snap: Mapping[str, Any], rendered: F.Rendered) -> list[dict]: + product = snap["product"] + if product and product["body"] in rendered.reads and rendered.produces == "solid": + return [self._point_product(product, rendered.slug)] + return [] + + def _apply(self, step: dict, *, forward: bool) -> None: + kind = step["kind"] + make, drop = (forward and kind == "created") or (not forward and kind == "deleted"), (forward and kind == "deleted") or (not forward and kind == "created") + try: + if make: + created = self._create(step["after"] if self._exists(step["after"]) else "end", step["code"], run=True) + step["id"] = created["id"] + elif drop: + self._remove(step["id"], expect=step["code"]) + else: # edited + old, new = (step["old"], step["new"]) if forward else (step["new"], step["old"]) + self._edit(step["id"], old, new, run=True) + except GuiError as exc: + raise GuiError(f"cannot {'redo' if forward else 'undo'}: the notebook changed since ({exc})", conflict=exc.conflict) from exc + + def _exists(self, ref: str) -> bool: + return ref in ("start", "end") or any(c["id"] == ref for c in self.state.cells) + + def _result(self, cell_id: str | None, steps: list[dict], name: str) -> dict[str, Any]: + report = self.state.cells_report(refresh=False) + cell = next((c for c in report["cells"] if c["id"] == cell_id), None) if cell_id else None + return { + "ok": True, + "name": name, + "cell": {k: cell[k] for k in ("id", "index", "status", "errors", "output", "console") if k in cell} if cell else None, + "state": self.snapshot(), + } + + +__all__ = ["FeatureService", "GuiError"] diff --git a/src/simplecadapi/editor/selection.py b/src/simplecadapi/editor/selection.py index 08898884..48157c6c 100644 --- a/src/simplecadapi/editor/selection.py +++ b/src/simplecadapi/editor/selection.py @@ -44,13 +44,12 @@ _RELATIVE_TOLERANCE = 0.01 -def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> str | None: - """A ``scad.ql`` expression selecting the one entity, or ``None``. +def selector_conditions(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> list[str] | None: + """The ``scad.ql.prop(...)`` conditions that single out one entity by its geometry, or ``None``. *properties* and *geometry* are the scene sidecar's entity fields. """ - factory = _FACTORY.get(kind) - if factory is None: + if kind not in _FACTORY: return None conditions: list[str] = [] ql_type = _QL_TYPES.get(str(geometry.get("type", ""))) @@ -70,10 +69,16 @@ def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapp low, high = size * (1 - _RELATIVE_TOLERANCE), size * (1 + _RELATIVE_TOLERANCE) conditions.append(f'scad.ql.prop("{name}", ">=", {_number(low)})') conditions.append(f'scad.ql.prop("{name}", "<=", {_number(high)})') - if not conditions: + return conditions or None + + +def selector_expression(kind: str, properties: Mapping[str, Any], geometry: Mapping[str, Any]) -> str | None: + """A ``scad.ql`` expression selecting the one entity, or ``None``.""" + conditions = selector_conditions(kind, properties, geometry) + if conditions is None: return None body = ",\n ".join(conditions) - return f"scad.ql.{factory}().where(scad.ql.and_(\n {body},\n)).exactly(1)" + return f"scad.ql.{_FACTORY[kind]}().where(scad.ql.and_(\n {body},\n)).exactly(1)" def selection_cell_code(items: Sequence[Mapping[str, Any]]) -> str: diff --git a/src/simplecadapi/editor/server.py b/src/simplecadapi/editor/server.py index 77f67fa9..6a0cc8b6 100644 --- a/src/simplecadapi/editor/server.py +++ b/src/simplecadapi/editor/server.py @@ -142,6 +142,9 @@ def __init__(self, workspace: Path, notebook: Path | None = None) -> None: self.selection_items: list[dict] = [] self.viewport_png: bytes | None = None self.cells: list[dict] = [] + from .gui_features import FeatureService + + self.features = FeatureService(self) # GUI-made features: commands and undo (see gui_features.py) self.specs: dict[str, ParamSpec] = {} self._pending_params: dict[str, float | int | bool] = {} self._rebuild_inflight = False @@ -1994,6 +1997,42 @@ async def api_cell_command(request: Request) -> JSONResponse: status = 409 if str(report.get("error", "")).startswith("conflict") else 400 if "error" in report else 200 return JSONResponse(report, status_code=status) + async def features_specs(request: Request) -> JSONResponse: + """The operations a person can add with a button, as typed forms (see features.py).""" + from .features import catalog + + return JSONResponse(catalog(), headers={"Cache-Control": "max-age=3600"}) + + async def features_state(request: Request) -> JSONResponse: + """The features of the open notebook, its bodies, the product, and the defaults a form opens with.""" + return JSONResponse(await asyncio.to_thread(state.features.snapshot)) + + async def features_command(request: Request) -> JSONResponse: + """``{op: add|edit|delete|use_as_product|undo|redo, ...}``: one GUI command on the open notebook.""" + from .gui_features import GuiError + + try: + body = json.loads(await request.body() or b"{}") + except json.JSONDecodeError: + return JSONResponse({"ok": False, "error": "body is not JSON"}, status_code=400) + body = body if isinstance(body, dict) else {} + op = str(body.get("op", "")) + handler = { + "add": state.features.add, + "edit": state.features.edit, + "delete": state.features.delete, + "use_as_product": state.features.use_as_product, + "undo": lambda _request: state.features.undo(), + "redo": lambda _request: state.features.redo(), + }.get(op) + if handler is None: + return JSONResponse({"ok": False, "error": f"unknown operation {op!r}"}, status_code=400) + try: + result = await asyncio.to_thread(handler, body) + except GuiError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=409 if exc.conflict else 400) + return JSONResponse(result) + async def sdk_index(request: Request) -> JSONResponse: """The SDK's public API (names and parameters), for the cell editor's completion.""" from .apiindex import build_index @@ -2136,6 +2175,9 @@ async def ws_endpoint(websocket) -> None: Route("/api/sdk", sdk_index, methods=["GET"]), Route("/api/cells", api_cells, methods=["GET"]), Route("/api/cells/{op}", api_cell_command, methods=["POST"]), + Route("/api/features/specs", features_specs, methods=["GET"]), + Route("/api/features", features_state, methods=["GET"]), + Route("/api/features", features_command, methods=["POST"]), Route("/api/workspace", workspace_list, methods=["GET"]), Route("/api/workspace/open", workspace_open, methods=["POST"]), Route("/api/workspace/new", workspace_new, methods=["POST"]), diff --git a/test/test_editor_features.py b/test/test_editor_features.py new file mode 100644 index 00000000..5445a8b1 --- /dev/null +++ b/test/test_editor_features.py @@ -0,0 +1,259 @@ +"""GUI-made features: every operation renders a cell that runs, uses only the SDK's real parameters, and cannot carry code.""" + +from __future__ import annotations + +import ast +import inspect + +import pytest + +import simplecadapi as scad +from simplecadapi.editor import features as F + +DEFAULTS = {spec.id: {slot.name: slot.default for slot in spec.slots if slot.kind != "select"} for spec in F.SPECS} + + +def _calls(source: str): + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "scad": + yield node + + +def _entity(shape_kind, entity, area=None, length=None, tags=()): + center = entity.get_center() + props = {"centroid": [center.x, center.y, center.z]} + if area is not None: + props["area"] = area + if length is not None: + props["length"] = length + return {"kind": shape_kind, "properties": props, "geometry": {"type": "plane" if shape_kind == "face" else "line"}, "tags": list(tags)} + + +def _run(rendered: F.Rendered, scope: dict) -> None: + """Run the params cell and the feature cell the way the notebook would (in order, one namespace).""" + if rendered.params: + exec("\n".join(rendered.params), scope) # noqa: S102 - the code under test is ours + exec(rendered.feature, scope) # noqa: S102 + + +def test_the_catalog_lists_every_operation_with_typed_slots(): + data = F.catalog() + ids = [spec["id"] for spec in data["specs"]] + assert len(ids) == len(set(ids)) >= 24 + for spec in data["specs"]: + assert spec["slots"], spec["id"] + assert {slot["kind"] for slot in spec["slots"]} <= {"length", "angle", "float", "count", "bool", "enum", "point", "body", "select", "points2d", "points3d"} + + +@pytest.mark.parametrize("spec", F.SPECS, ids=lambda s: s.id) +def test_every_template_uses_real_sdk_functions_and_parameters(spec): + """The drift test: an SDK change that breaks a template breaks this, not a user.""" + picks = {slot.name: [{"kind": slot.entity, "properties": {"centroid": [0, 0, 0], "area": 10.0, "length": 5.0}, "geometry": {"type": "plane"}}] for slot in spec.slots if slot.kind == "select"} + bodies = {"a": "solid", "b": "solid", "many": "solids"} + values = {k: (v if v is not None else None) for k, v in DEFAULTS[spec.id].items()} + for slot in spec.slots: + if slot.kind == "body": + values[slot.name] = "a" + rendered = F.render_feature(spec.id, "probe", values, bodies=bodies, selections=picks) + seen = 0 + for call in _calls(rendered.feature): + function = getattr(scad, call.func.attr, None) + assert function is not None, f"{spec.id}: scad.{call.func.attr} does not exist" + names = set(inspect.signature(function).parameters) + for keyword in call.keywords: + assert keyword.arg in names, f"{spec.id}: scad.{call.func.attr} has no parameter {keyword.arg!r}" + seen += 1 + assert seen, spec.id + ast.parse("\n".join(rendered.params)) + assert rendered.feature.splitlines()[0] == f"# ---- feature: probe ({spec.role}) ----" + assert rendered.feature.splitlines()[1].startswith("# gui: ") + + +def test_the_primitives_and_profiles_make_one_valid_solid_each(): + with scad.GraphSession(graph_id="features-primitives"): + for spec in F.SPECS: + if any(slot.kind in ("body", "select") for slot in spec.slots): + continue + scope: dict = {"scad": scad} + rendered = F.render_feature(spec.id, "made", DEFAULTS[spec.id], bodies={}) + _run(rendered, scope) + made = scope["made"] + assert made.get_volume() > 0, spec.id + + +def test_a_chain_of_features_builds_and_the_picked_face_still_resolves_after_a_hole(): + with scad.GraphSession(graph_id="features-chain"): + scope: dict = {"scad": scad} + bodies: dict[str, str] = {} + + def add(spec_id, slug, values=None, selections=None): + rendered = F.render_feature(spec_id, slug, {**DEFAULTS[spec_id], **(values or {})}, bodies=bodies, selections=selections, known_slugs=set(bodies)) + _run(rendered, scope) + bodies[slug] = rendered.produces + return rendered + + add("box", "base") + base = scope["base"] + top = scad.ql.faces().where(scad.ql.tag("base.top")).exactly(1).resolve(base)[0] + add("hole", "bore", {"body": "base", "position": [0, 0, 20], "direction": "-z", "diameter": 6.0, "depth": 20.0}) + assert scope["bore"].get_volume() < base.get_volume() + # the top face was tagged by the box; a feature after the hole selects it through the tag + picked = _entity("face", top, area=top.get_area(), tags=["base.top"]) + shelled = add("shell", "case", {"body": "bore", "thickness": 2.0}, {"faces": [picked]}) + assert "scad.ql.tag(\"base.top\")" in shelled.feature and "by_geometry" in shelled.feature + assert scope["case"].get_volume() < scope["bore"].get_volume() + + +def test_a_fillet_picks_edges_by_geometry_and_counts_them(): + with scad.GraphSession(graph_id="features-fillet"): + scope: dict = {"scad": scad} + bodies = {} + box = F.render_feature("box", "base", DEFAULTS["box"], bodies=bodies) + _run(box, scope) + bodies["base"] = "solid" + base = scope["base"] + vertical = scad.ql.edges().where(scad.ql.and_(scad.ql.prop("geom.center.z", ">=", 9.0), scad.ql.prop("geom.center.z", "<=", 11.0), scad.ql.prop("geom.length", ">=", 19.0))).resolve(base) + assert len(vertical) == 4 + items = [_entity("edge", e, length=e.get_length()) for e in vertical[:2]] + rendered = F.render_feature("fillet", "round", {"body": "base", "radius": 2.0}, bodies=bodies, selections={"edges": items}) + assert ".exactly(2)" in rendered.feature + _run(rendered, scope) + assert scope["round"].get_volume() < base.get_volume() + # the same recipe on a body where the geometry moved selects nothing: an error, not another edge + scope2 = {"scad": scad, "base": scad.make_box_rsolid(width=10, height=10, depth=10, bottom_face_center=(100, 100, 100))} + with pytest.raises(Exception): + exec(rendered.feature, scope2) # noqa: S102 + + +@pytest.mark.parametrize( + "spec_id, values, message", + [ + ("box", {"width": -1.0}, "greater than zero"), + ("box", {"width": "ten"}, "a number is required"), + ("box", {"origin": [0, 0]}, "three numbers"), + ("cylinder", {"axis": "+w"}, "choose one of"), + ("cut", {"base": "a; import os", "tool": "b"}, "is not a body"), + ("cut", {"base": "nope", "tool": "b"}, "is not a body"), + ("linear_pattern", {"shape": "a", "count": 2.5}, "whole number"), + ("mirror", {"body": "a", "merge": "yes"}, "true or false"), + ], +) +def test_bad_values_are_refused_with_a_message_and_never_reach_the_source(spec_id, values, message): + bodies = {"a": "solid", "b": "solid"} + base = {**DEFAULTS[spec_id], **({"base": "a", "tool": "b"} if spec_id == "cut" else {})} + with pytest.raises(F.FeatureError, match=message): + F.render_feature(spec_id, "x", {**base, **values}, bodies=bodies) + + +def test_names_are_checked_and_made_unique(): + taken = {"base", "BASE", "block"} + assert F.default_slug("box", taken) == "box" + assert F.default_slug("extrude_rect", taken) == "block_2" + with pytest.raises(F.FeatureError): + F.check_slug("Base", taken) + with pytest.raises(F.FeatureError, match="already used"): + F.check_slug("base", taken) + with pytest.raises(F.FeatureError): + F.check_slug("a; b", taken) + + +def test_the_gui_line_round_trips_and_code_cells_are_left_alone(): + rendered = F.render_feature("box", "base", DEFAULTS["box"], bodies={}) + parsed = F.parse_gui(rendered.feature) + assert parsed and parsed["spec"] == "box" and parsed["values"]["width"] == 40.0 + assert F.parse_gui("# ---- feature: x (build) ----\nx = scad.make_box_rsolid(width=1, height=1, depth=1)") is None + assert F.parse_gui("# ---- feature: x (build) ----\n# gui: {not json") is None + assert F.parse_gui('# ---- feature: x (build) ----\n# gui: {"spec": "no_such_op"}') is None + + +def test_numbers_become_slider_params_and_counts_plain_values(): + rendered = F.render_feature("linear_pattern", "row", {**DEFAULTS["linear_pattern"], "shape": "a"}, bodies={"a": "solid"}) + joined = "\n".join(rendered.params) + assert "ROW_SPACING = scad.var(name=\"row_spacing\"" in joined and "# ui: slider(" in joined + assert "ROW_COUNT = 3 # ui: number(2, 500, step=1" in joined + assert rendered.params[0] == "# ---- params: row ----" + + +# ---- the commands, against a real kernel ---------------------------------------------------------------------------------- + + +def _wait_idle(state, timeout=120): + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + statuses = {c.get("status") for c in state.cells} + if statuses <= {"idle", "stale", None}: + return + time.sleep(0.3) + + +def test_the_commands_add_edit_undo_and_redo_against_the_real_editor(tmp_path): + import threading + + import uvicorn + + from simplecadapi.editor.gui_features import GuiError + from simplecadapi.editor.server import WorkspaceState, create_app + from simplecadapi.editor.templates import create_notebook + + path = create_notebook(tmp_path, "bracket", "part") + state = WorkspaceState(tmp_path) + state.port = 17732 + server = uvicorn.Server(uvicorn.Config(create_app(state), port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + state.start_kernel(path) + service = state.features + before = [c["code"] for c in state.cells] + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base"] and snap["tip"] == "base" + assert snap["product"]["body"] == "base" and snap["features"][0]["gui"] is None # the template's cell is code + + # a hole: two cells appear (numbers, then the feature), the product follows the new body + result = service.add({"spec": "hole", "values": {"position": [0.0, 0.0, 10.0], "direction": "-z", "diameter": 8.0, "depth": 12.0}}) + _wait_idle(state) + assert result["ok"] and result["name"] == "hole" + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base", "hole"] and snap["tip"] == "hole" + assert snap["product"]["body"] == "hole" + assert snap["features"][1]["gui"]["spec"] == "hole" and snap["features"][1]["params_cell"] + source = path.read_text() + assert "# gui: " in source and "HOLE_DIAMETER = scad.var" in source + assert all(not f["errors"] for f in snap["features"]), snap["features"] + + # editing the diameter rewrites both cells and keeps the name + service.edit({"cell": snap["features"][1]["cell"], "values": {"diameter": 10.0}}) + _wait_idle(state) + assert "default=10.0" in path.read_text() and snap["features"][1]["slug"] == "hole" + + # a fillet on the top rim picked by geometry; the count is written into the recipe + rim = {"kind": "edge", "properties": {"centroid": [0.0, 0.0, 10.0], "length": 2 * 3.141592653589793 * 25.0}, "geometry": {"type": "circle"}} + service.add({"spec": "fillet", "values": {"radius": 1.0}, "selections": {"edges": [rim]}}) + _wait_idle(state) + snap = service.snapshot() + assert snap["tip"] == "fillet" and snap["product"]["body"] == "fillet" + assert ".exactly(1)" in path.read_text() + + # undo walks back through the three commands, and the file is what it was + for _ in range(3): + service.undo() + _wait_idle(state) + assert [c["code"] for c in state.cells] == before + assert service.snapshot()["redo"] == 3 + service.redo() + _wait_idle(state) + assert [f["slug"] for f in service.snapshot()["features"]] == ["base", "hole"] + + # an edit made behind the GUI's back wins: undoing over it is refused, not forced + hole_cell = service.snapshot()["features"][1]["cell"] + state.cell_command("edit", {"cell": hole_cell, "code": "# ---- feature: hole (subtract) ----\nhole = base", "run": True}) + _wait_idle(state) + with pytest.raises(GuiError) as refused: + service.undo() + assert "changed since" in str(refused.value) + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True From 3171a01a936b7f31f9f08e9c40e42f7b77747cee Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:22:50 +0800 Subject: [PATCH 007/131] editor: a blank notebook, the product cell with the first GUI feature, and the protocol in the skill sca new --kind blank writes a part notebook with no cells; the first GUI feature adds the product cell that builds the Part from it, and later features that consume that body move it along. editor-collaboration.md says what a GUI cell looks like, that GUI commands never overwrite an edit made in between, and how the inline selection recipes behave. Co-Authored-By: Claude Sonnet 5.5 --- .../domains/editor-collaboration.md | 27 ++++++++++++++ src/simplecadapi/editor/cli.py | 4 +-- src/simplecadapi/editor/gui_features.py | 9 +++++ src/simplecadapi/editor/templates.py | 3 ++ test/test_editor_features.py | 36 +++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/skill/references/domains/editor-collaboration.md b/docs/skill/references/domains/editor-collaboration.md index 6a59c31e..9a4ff2ed 100644 --- a/docs/skill/references/domains/editor-collaboration.md +++ b/docs/skill/references/domains/editor-collaboration.md @@ -197,6 +197,33 @@ Errors there are the same errors `sca snapshot` reports per cell. A module that does not compile is reported as `setup failed` with the file and line; fix that file first, since every cell is blocked until it compiles. +## Features the person made with buttons + +The editor also has a Features tab and an operation palette (box, cylinder, extrude, hole, fillet, patterns ...). A click +writes two ordinary cells, so you read and edit them like any other: + +```python +# ---- params: hole ---- +HOLE_DIAMETER = scad.var(name="hole_diameter", default=6.0, unit="mm", comment="Diameter") # ui: slider(...) + +# ---- feature: hole (subtract) ---- +# gui: {"spec":"hole","v":1,"values":{...}} +...one operation that makes the new name `hole`... +``` + +- The `# gui:` line is how the form is rebuilt. A cell without it is code and the form never rewrites it. To take a + GUI feature over as code, delete the `# gui:` line (or ask the person to); to change a number, edit the `# params:` cell + or the form, not the call. +- GUI commands carry the code they were made from: if you edited the cell in between, the command is refused (409), it + does not overwrite you. The same holds for the person's undo of a GUI command. +- Selections in a GUI cell are written inline, resolved against the body the cell receives: the face tags the box, cylinder + or extrude was given (`.top`, `.side` ... they survive booleans, not fillets or chamfers) first, the picked + geometry second, with the count checked (`.exactly(n)`). If the model moved so that nothing matches, the cell fails and + the feature turns red: ask the person to pick again, never widen the selector until it matches. +- A blank notebook (`sca new NAME --kind blank`) has no cells; the first feature adds the product cell + (` = scad.Part(...)`), and later features that consume the body it builds from move it along. +- Unattended: the buttons are not available; write the cells yourself (see `discipline/notebook-cell-design.md`). + ## Rules of thumb - Re-read the cell before editing (`sca cell get`, or the file): the person edits and diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index a199c585..dbc757fa 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -9,7 +9,7 @@ sca viewport [WORKSPACE] [--out PATH] # the image of what the person sees sca cell ls|get|put|insert|rm|run # edit the cells of the notebook the editor has open sca cell open NOTEBOOK [--wait S] # have the editor open another notebook - sca new NAME [--kind part|assembly] # a new notebook; the editor lists it at once + sca new NAME [--kind part|assembly|blank] # a new notebook; the editor lists it at once """ from __future__ import annotations @@ -138,7 +138,7 @@ def writable(parser: argparse.ArgumentParser) -> None: "current directory). A running editor on that workspace lists it without a reload.", ) new.add_argument("name") - new.add_argument("--kind", choices=("part", "assembly"), default="part") + new.add_argument("--kind", choices=("part", "assembly", "blank"), default="part") new.add_argument("--workspace", "-w", type=Path, default=None) new.set_defaults(handler=_new_notebook, printer=_print_json) diff --git a/src/simplecadapi/editor/gui_features.py b/src/simplecadapi/editor/gui_features.py index e01ff47b..7cdf3ec9 100644 --- a/src/simplecadapi/editor/gui_features.py +++ b/src/simplecadapi/editor/gui_features.py @@ -164,6 +164,7 @@ def add(self, request: Mapping[str, Any]) -> dict[str, Any]: made = self._create(feature_anchor, rendered.feature, run=True) steps.append(made) steps.extend(self._repoint_product(snap, rendered)) + steps.extend(self._first_product(snap, rendered, made["id"], taken)) self._record(steps) return self._result(made["id"], steps, slug) @@ -355,6 +356,14 @@ def _repoint_product(self, snap: Mapping[str, Any], rendered: F.Rendered) -> lis return [self._point_product(product, rendered.slug)] return [] + def _first_product(self, snap: Mapping[str, Any], rendered: F.Rendered, after: str, taken: set[str]) -> list[dict]: + """A notebook with no product yet gets one, built from its first body (a blank notebook starts without cells).""" + if snap["product"] is not None or rendered.produces != "solid" or any("Part(" in str(c.get("code", "")) for c in self.state.cells): + return [] + stem = re.sub(r"\W", "_", getattr(self.state.notebook, "stem", "part") or "part").strip("_").lower() or "part" + variable = stem if stem not in taken and stem not in F._RESERVED and not stem[0].isdigit() else f"{stem}_part" + return [self._create(after, f"{variable} = scad.Part(part_id=scad.notebook_id(), body={rendered.slug})", run=True)] + def _apply(self, step: dict, *, forward: bool) -> None: kind = step["kind"] make, drop = (forward and kind == "created") or (not forward and kind == "deleted"), (forward and kind == "deleted") or (not forward and kind == "created") diff --git a/src/simplecadapi/editor/templates.py b/src/simplecadapi/editor/templates.py index b03aa09d..8cd5d09f 100644 --- a/src/simplecadapi/editor/templates.py +++ b/src/simplecadapi/editor/templates.py @@ -94,6 +94,9 @@ def render_notebook(name: str, kind: str = "part", parts: list[tuple[str, str]] raise ValueError(f"invalid notebook name: {name!r}") variable = name.replace("-", "_") header = _HEADER.format(id=name) + if kind == "blank": + # No cells: the GUI adds the first feature, and the product cell with it (see gui_features.py). + return header + _RUNNER if kind == "part": return header + _PART.format(variable=variable) + _RUNNER if kind != "assembly": diff --git a/test/test_editor_features.py b/test/test_editor_features.py index 5445a8b1..44ea9e76 100644 --- a/test/test_editor_features.py +++ b/test/test_editor_features.py @@ -257,3 +257,39 @@ def test_the_commands_add_edit_undo_and_redo_against_the_real_editor(tmp_path): if state.kernel is not None: state.kernel.stop() server.should_exit = True + + +def test_a_blank_notebook_gets_its_product_with_the_first_feature(tmp_path): + import threading + + import uvicorn + + from simplecadapi.editor.server import WorkspaceState, create_app + from simplecadapi.editor.templates import create_notebook + + path = create_notebook(tmp_path, "plate", "blank") + assert "@app.cell" not in path.read_text() + state = WorkspaceState(tmp_path) + state.port = 17733 + server = uvicorn.Server(uvicorn.Config(create_app(state), port=state.port, log_level="error")) + threading.Thread(target=server.run, daemon=True).start() + state.hub.attach_loop(__import__("asyncio").new_event_loop()) + try: + state.start_kernel(path) + service = state.features + assert service.snapshot()["features"] == [] and service.snapshot()["product"] is None + service.add({"spec": "box", "name": "base", "values": {}}) + _wait_idle(state) + snap = service.snapshot() + assert [f["slug"] for f in snap["features"]] == ["base"] + assert snap["product"] == {"cell": snap["product"]["cell"], "body": "base"} + assert "plate = scad.Part(part_id=scad.notebook_id(), body=base)" in path.read_text() + service.add({"spec": "cylinder", "values": {"radius": 5.0, "height": 40.0, "origin": [0.0, 0.0, -10.0]}}) + service.add({"spec": "cut", "values": {}}) # base is the second-last open body, the cylinder the last: it is cut out + _wait_idle(state) + snap = service.snapshot() + assert snap["product"]["body"] == "cut" and all(not f["errors"] for f in snap["features"]), snap["features"] + finally: + if state.kernel is not None: + state.kernel.stop() + server.should_exit = True From ff6c8a573eb5222206508042910f6fe2e7bd027c Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Tue, 3 Nov 2026 01:44:08 +0800 Subject: [PATCH 008/131] editor-web: a feature tree, a command bar and spec-generated parameter dialogs The Features tab lists the notebook's features (GUI-made ones editable, code ones marked), shows each one's errors, offers undo/redo and a banner when the product does not build from the tip. Operations are added from a floating command bar (category chips, keyword search, Up/Down/Enter, docked top or bottom, collapsible) fed by a command registry; each one opens a non-modal dialog generated from the operation's typed slots, with viewport picks for faces and edges and a 'use picked face' helper for holes. Co-Authored-By: Claude Sonnet 5.5 --- editor-web/package-lock.json | 1486 ++++++++++++++++++- editor-web/package.json | 8 +- editor-web/src/App.tsx | 115 +- editor-web/src/components/CommandBar.tsx | 242 +++ editor-web/src/components/FeatureDialog.tsx | 413 ++++++ editor-web/src/components/FeatureTree.tsx | 154 ++ editor-web/src/components/Header.tsx | 9 + editor-web/src/components/feature-icons.ts | 30 + editor-web/src/components/ui/dialog.tsx | 52 + editor-web/src/components/ui/input.tsx | 16 + editor-web/src/components/ui/label.tsx | 15 + editor-web/src/components/ui/select.tsx | 69 + editor-web/src/components/ui/switch.tsx | 20 + editor-web/src/lib/commands.test.ts | 41 + editor-web/src/lib/commands.ts | 115 ++ editor-web/src/lib/features.test.ts | 117 ++ editor-web/src/lib/features.ts | 358 +++++ editor-web/src/lib/useFeatures.ts | 54 + editor-web/vite.config.ts | 4 +- 19 files changed, 3233 insertions(+), 85 deletions(-) create mode 100644 editor-web/src/components/CommandBar.tsx create mode 100644 editor-web/src/components/FeatureDialog.tsx create mode 100644 editor-web/src/components/FeatureTree.tsx create mode 100644 editor-web/src/components/feature-icons.ts create mode 100644 editor-web/src/components/ui/dialog.tsx create mode 100644 editor-web/src/components/ui/input.tsx create mode 100644 editor-web/src/components/ui/label.tsx create mode 100644 editor-web/src/components/ui/select.tsx create mode 100644 editor-web/src/components/ui/switch.tsx create mode 100644 editor-web/src/lib/commands.test.ts create mode 100644 editor-web/src/lib/commands.ts create mode 100644 editor-web/src/lib/features.test.ts create mode 100644 editor-web/src/lib/features.ts create mode 100644 editor-web/src/lib/useFeatures.ts diff --git a/editor-web/package-lock.json b/editor-web/package-lock.json index 64fa773b..464564b2 100644 --- a/editor-web/package-lock.json +++ b/editor-web/package-lock.json @@ -31,10 +31,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.4.1", "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-tooltip": "^1.2.16", "@types/node": "^24.13.3", @@ -43,7 +47,8 @@ "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", "typescript": "~6.0.2", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } }, "node_modules/@babel/runtime": { @@ -755,6 +760,227 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", @@ -763,26 +989,491 @@ "license": "MIT", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.17.tgz", + "integrity": "sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz", + "integrity": "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.13", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz", + "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", - "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-collection": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-effect-event": "0.0.5" + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -799,15 +1490,28 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", - "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.4" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-direction": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", + "dev": true, + "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -818,22 +1522,50 @@ } } }, - "node_modules/@radix-ui/react-popper": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", - "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", + "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-arrow": "1.1.17", "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", - "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { @@ -851,14 +1583,14 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", - "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { @@ -876,10 +1608,10 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", - "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", "dev": true, "license": "MIT", "dependencies": { @@ -900,14 +1632,14 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", - "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.3" + "@radix-ui/react-slot": "1.5.0" }, "peerDependencies": { "@types/react": "*", @@ -924,57 +1656,93 @@ } } }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", - "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-collection": "1.1.15", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-id": "1.1.4", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", - "@radix-ui/react-use-controllable-state": "1.2.6", - "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { "optional": true } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", - "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.3", - "@radix-ui/primitive": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.5", - "@radix-ui/react-context": "1.2.2", - "@radix-ui/react-direction": "1.1.4", - "@radix-ui/react-presence": "1.1.10", - "@radix-ui/react-primitive": "2.1.10", - "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-layout-effect": "1.1.4" }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz", + "integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", @@ -1035,27 +1803,162 @@ }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.4.1.tgz", + "integrity": "sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-size": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", - "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.5" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2107,6 +3010,31 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", @@ -2246,6 +3174,87 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2304,6 +3313,13 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.25.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", @@ -2317,6 +3333,33 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2354,6 +3397,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2672,6 +3725,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.85.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", @@ -2788,6 +3855,55 @@ "react": "^19.3.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-resizable-panels": { "version": "4.13.3", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.13.3.tgz", @@ -2798,6 +3914,29 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -2846,6 +3985,13 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", @@ -2887,6 +4033,26 @@ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2903,6 +4069,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "devOptional": true, + "license": "0BSD" + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -2933,6 +4106,51 @@ "devOptional": true, "license": "MIT" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", @@ -3010,11 +4228,117 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } } } } diff --git a/editor-web/package.json b/editor-web/package.json index 2b2afbd5..b90f4f95 100644 --- a/editor-web/package.json +++ b/editor-web/package.json @@ -8,6 +8,7 @@ "build": "tsc --noEmit -p tsconfig.json && vite build", "lint": "oxlint", "preview": "vite preview", + "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -34,10 +35,14 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-label": "^2.1.17", "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.4.1", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slider": "^1.4.7", "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.4.1", "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-tooltip": "^1.2.16", "@types/node": "^24.13.3", @@ -46,6 +51,7 @@ "@vitejs/plugin-react": "^6.1.1", "oxlint": "^1.81.0", "typescript": "~6.0.2", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.1.0" } } diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 4e43541b..573bbe7c 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -2,6 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CellEditor, type NotebookCell, type NotebookFeature, type ParamMeta } from "@/components/CellEditor"; import { Dock } from "@/components/Dock"; +import { FeatureDialog, type DialogRequest } from "@/components/FeatureDialog"; +import { CommandBar } from "@/components/CommandBar"; +import { FEATURE_ICONS } from "@/components/feature-icons"; +import { FeatureTree } from "@/components/FeatureTree"; +import { commandsFromSpecs } from "@/lib/commands"; import { Header } from "@/components/Header"; import { Inspector } from "@/components/Inspector"; import { LoadingMask } from "@/components/LoadingMask"; @@ -12,6 +17,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getWorkspace, newNotebook, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; import { parsePreview, type PreviewPart } from "@/lib/preview"; import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; +import { useFeatures } from "@/lib/useFeatures"; +import type { FeatureCommand, FeatureRow } from "@/lib/features"; import { useRunLog } from "@/lib/runlog"; import { useTheme } from "@/lib/theme"; import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection"; @@ -62,6 +69,14 @@ export function App() { // slider, one cell) are not veiled. const [loading, setLoading] = useState("connecting"); + // GUI features: the REST state is richer than `notebook.features`, so it is refreshed on every cells event. + const gui = useFeatures(); + const refreshFeatures = gui.refresh; + const [dialog, setDialog] = useState(null); + const [guiBusy, setGuiBusy] = useState(false); + const [guiError, setGuiError] = useState(null); + const modeBeforeDialog = useRef(null); + const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null; const theme = useTheme(); const log = useRunLog(); @@ -128,6 +143,7 @@ export function App() { // an empty workspace: the way forward is the workspace tab's "new" if (!envelope["notebook"]) setLeftTab("workspace"); setKernelAlive(Boolean(envelope["kernel"])); + refreshFeatures(); // No notebook: nothing to wait for. Else the model is on its way. if (hasModel.current) return; const stage = envelope["notebook"] ? (envelope["kernel"] ? "building the model" : "starting the kernel") : null; @@ -146,6 +162,8 @@ export function App() { setParams([]); setFeatures([]); setPicked([]); + setDialog(null); + refreshFeatures(); setNotebook(String(envelope["notebook"] ?? "")); setCurrentPath((envelope["path"] as string) ?? null); setStatus(`opening ${envelope["notebook"]}…`); @@ -191,6 +209,7 @@ export function App() { const next = (envelope["cells"] as NotebookCell[]) ?? []; setCells(next); log.knowCells(next); + refreshFeatures(); }); // A notebook was added or removed under the workspace (an agent wrote one). socket.on("workspace.changed", () => refreshWorkspace()); @@ -255,7 +274,7 @@ export function App() { socket.connect(); return () => socket.close(); - }, [loadPreviewBytes, log.push, log.cellStatus, log.knowCells, log.resetBatch, log.modelShown]); + }, [loadPreviewBytes, refreshFeatures, log.push, log.cellStatus, log.knowCells, log.resetBatch, log.modelShown]); // -- selection ------------------------------------------------------------------- @@ -319,6 +338,72 @@ export function App() { return () => window.clearTimeout(timer); }, [loading]); + // -- GUI features ------------------------------------------------------------------------------ + + const openDialog = useCallback( + (request: DialogRequest) => { + if (modeBeforeDialog.current === null) modeBeforeDialog.current = selectionMode; + setDialog(request); + }, + [selectionMode], + ); + const closeDialog = useCallback(() => { + setDialog(null); + if (modeBeforeDialog.current !== null) setSelectionMode(modeBeforeDialog.current); + modeBeforeDialog.current = null; + }, []); + + /** A command from the dialog: rejects with the server's message (shown in the dialog), closes on success. */ + const submitFeature = useCallback( + async (command: FeatureCommand) => { + const result = await gui.run(command); + log.push("ok", command.op === "undo" || command.op === "redo" ? command.op : `${command.op} ${result.name}`); + closeDialog(); + }, + [gui.run, log.push, closeDialog], + ); + + /** A command from the tree or the shortcuts: its failure shows above the tree. */ + const runFeature = useCallback( + async (command: FeatureCommand) => { + setGuiBusy(true); + setGuiError(null); + try { + const result = await gui.run(command); + log.push("ok", command.op === "undo" || command.op === "redo" ? command.op : `${command.op} ${result.name}`); + } catch (error) { + setGuiError(error instanceof Error ? error.message : String(error)); + } finally { + setGuiBusy(false); + } + }, + [gui.run, log.push], + ); + const undo = useCallback(() => void runFeature({ op: "undo" }), [runFeature]); + const redo = useCallback(() => void runFeature({ op: "redo" }), [runFeature]); + + // Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z undo and redo a feature change, unless a text field has the focus. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.key.toLowerCase() !== "z") return; + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, [contenteditable=true], .cm-editor")) return; + event.preventDefault(); + if (event.shiftKey) redo(); + else undo(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [undo, redo]); + + // The command registry the bar reads: today the feature catalog, later sketch and assembly commands too. + const commands = useMemo( + () => commandsFromSpecs(gui.catalog?.specs ?? [], (spec) => openDialog({ spec }), FEATURE_ICONS), + [gui.catalog, openDialog], + ); + + const handleEditFeature = (feature: FeatureRow, spec: DialogRequest["spec"]) => openDialog({ spec, edit: feature }); + // -- actions ----------------------------------------------------------------------- const handleOpen = (path: string) => { @@ -345,6 +430,9 @@ export function App() { status={status} themePreference={theme.preference} onTheme={theme.cycle} + features={gui.state} + onUndo={undo} + onRedo={redo} running={running} onStop={handleStop} onRunAll={() => { @@ -357,10 +445,24 @@ export function App() { + features notebook workspace {moduleView && {moduleView.name}} + + void runFeature({ op: "delete", cell: feature.cell })} + onUseProduct={(body) => void runFeature({ op: "use_as_product", body })} + onUndo={undo} + onRedo={redo} + /> + -
+
+
@@ -446,6 +549,14 @@ export function App() { + (key: string, fallback: T, parse: (raw: string) => T | null): T { + try { + const raw = window.localStorage.getItem(key); + return raw === null ? fallback : (parse(raw) ?? fallback); + } catch { + return fallback; + } +} +function writeStore(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + /* private window or blocked storage: the bar still works */ + } +} + +/** A floating command bar over the viewport: category chips, a search box, a short result list. + * `/` or Ctrl/Cmd+K focuses the search; Up/Down move, Enter runs, Esc closes the list. + * It collapses to a pill and docks at the bottom (default) or the top. */ +export function CommandBar({ commands }: { commands: Command[] }) { + const [dock, setDock] = useState<"top" | "bottom">(() => readStore<"top" | "bottom">(DOCK_KEY, "bottom", (raw) => (raw === "top" || raw === "bottom" ? raw : null))); + const [collapsed, setCollapsed] = useState(false); + const [recent, setRecent] = useState(() => readStore(RECENT_KEY, [], (raw) => (Array.isArray(JSON.parse(raw)) ? (JSON.parse(raw) as string[]) : null))); + const [query, setQuery] = useState(""); + const [group, setGroup] = useState(null); + const [open, setOpen] = useState(false); + const [highlight, setHighlight] = useState(0); + const [focusAsked, setFocusAsked] = useState(0); + const root = useRef(null); + const input = useRef(null); + const listRef = useRef(null); + + const groups = useMemo(() => commandGroups(commands), [commands]); + const results = useMemo(() => { + const pool = group && !query.trim() ? commands.filter((command) => command.group === group) : commands; + return searchCommands(pool, query, recent); + }, [commands, group, query, recent]); + const shown = open ? results : []; + const at = Math.min(highlight, Math.max(shown.length - 1, 0)); + + const run = useCallback( + (command: Command) => { + const next = pushRecent(recent, command.id); + setRecent(next); + writeStore(RECENT_KEY, JSON.stringify(next)); + setOpen(false); + setQuery(""); + setGroup(null); + input.current?.blur(); + command.run(); + }, + [recent], + ); + + // `/` and Ctrl/Cmd+K focus the search (`/` only when no text field has the focus). + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const typing = Boolean(target?.closest("input, textarea, select, [contenteditable=true], .cm-editor")); + const palette = (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "k"; + const slash = event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey && !typing; + if (!palette && !slash) return; + event.preventDefault(); + setCollapsed(false); + setFocusAsked((count) => count + 1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + useEffect(() => { + if (focusAsked > 0 && !collapsed) { + input.current?.focus(); + input.current?.select(); + setOpen(true); + setHighlight(0); + } + }, [focusAsked, collapsed]); + + // A click outside closes the list. + useEffect(() => { + const onDown = (event: MouseEvent) => { + if (root.current && !root.current.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, []); + + useEffect(() => { + listRef.current?.querySelector("[data-active=true]")?.scrollIntoView({ block: "nearest" }); + }, [at, shown.length]); + + const onInputKey = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + setOpen(true); + return; + } + const step = event.key === "ArrowDown" ? 1 : -1; + setHighlight((at + step + shown.length) % Math.max(shown.length, 1)); + } else if (event.key === "Enter") { + event.preventDefault(); + const command = shown[at]; + if (command) run(command); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (open) setOpen(false); + else input.current?.blur(); + } + }; + + const bottom = dock === "bottom"; + const toggleDock = () => { + const next = bottom ? "top" : "bottom"; + setDock(next); + writeStore(DOCK_KEY, next); + }; + const place = bottom ? "bottom-3" : "top-3"; + + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {shown.length > 0 && ( +
    + {shown.map((command, index) => { + const Icon = command.icon; + return ( +
  • setHighlight(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => run(command)} + > + {Icon ? : } + {command.label} + {command.description} + {command.group} +
  • + ); + })} +
+ )} +
+
+ {groups.map((name) => ( + + ))} +
+
+ + + +
+
+
+
+ ); +} diff --git a/editor-web/src/components/FeatureDialog.tsx b/editor-web/src/components/FeatureDialog.tsx new file mode 100644 index 00000000..67ee69a9 --- /dev/null +++ b/editor-web/src/components/FeatureDialog.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from "react"; +import { MousePointerClick } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input, Textarea } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectLabel, SelectGroup, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + bodyChoices, + filterSelection, + formToValues, + holeFromFace, + initialForm, + isDescribed, + pickedPayload, + suggestName, + validateName, + type FeatureCommand, + type FeatureRow, + type FeatureState, + type PickedItem, + type RawForm, + type Slot, + type Spec, +} from "@/lib/features"; +import type { SelectionItem } from "@/lib/selection"; +import type { SelectionMode } from "@/scene/Viewport"; + +export type DialogRequest = { spec: Spec; edit?: FeatureRow }; + +/** The form of one operation, generated from its spec. A docked card, not a modal: the viewport stays live. */ +export function FeatureDialog({ + request, + state, + selection, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest | null; + state: FeatureState; + selection: SelectionItem[]; + onSelectionMode: (mode: SelectionMode) => void; + /** Runs the command; rejects with the server's message, shown inline. */ + onSubmit: (command: FeatureCommand) => Promise; + onClose: () => void; +}) { + return ( + !open && onClose()}> + {request && ( + + )} + + ); +} + +function FeatureForm({ + request, + state, + selection, + onSelectionMode, + onSubmit, + onClose, +}: { + request: DialogRequest; + state: FeatureState; + selection: SelectionItem[]; + onSelectionMode: (mode: SelectionMode) => void; + onSubmit: (command: FeatureCommand) => Promise; + onClose: () => void; +}) { + const { spec, edit } = request; + const editing = Boolean(edit); + const [form, setForm] = useState(() => initialForm(spec, state.defaults[spec.id] ?? {}, edit?.gui?.values ?? {})); + const [name, setName] = useState(edit?.slug ?? ""); + const [busy, setBusy] = useState(false); + const [serverError, setServerError] = useState(null); + const [touched, setTouched] = useState(false); + const [note, setNote] = useState(null); + + // Picks present when the form opened: in edit mode only a different pick replaces the recipe. + const [initialKeys] = useState(() => new Set(selection.map((item) => item.key))); + const selectSlots = spec.slots.filter((slot) => slot.kind === "select"); + const picksOf = (slot: Slot) => filterSelection(selection, slot.entity ?? "face"); + const replaced = (slot: Slot) => { + const keys = picksOf(slot).map((item) => item.key); + if (!keys.length) return false; + return !editing || keys.length !== initialKeys.size || keys.some((key) => !initialKeys.has(key)); + }; + + // The viewport picks what the form asks for while it is open, and goes back after. + const wanted = (selectSlots[0]?.entity ?? (spec.id === "hole" ? "face" : null)) as SelectionMode | null; + useEffect(() => { + if (wanted) onSelectionMode(wanted); + }, [wanted, onSelectionMode]); + + const set = (slot: string, value: RawForm[string]) => { + setForm((previous) => ({ ...previous, [slot]: value })); + setServerError(null); + }; + + const { values, errors } = useMemo(() => formToValues(spec, form), [spec, form]); + const nameError = editing ? null : validateName(name, state.taken); + const suggested = suggestName(spec.id, state.taken); + + const selectionErrors: Record = {}; + const selections: Record = {}; + for (const slot of selectSlots) { + const picks = picksOf(slot); + if (replaced(slot)) { + if (picks.some((item) => !isDescribed(item))) selectionErrors[slot.name] = "details of the picked items are still loading"; + else selections[slot.name] = picks.map(pickedPayload); + } else if (!editing && !slot.optional) selectionErrors[slot.name] = `pick ${slot.entity === "edge" ? "edges" : "faces"} in the viewport`; + } + + const allErrors = { ...errors, ...selectionErrors }; + const invalid = Object.keys(allErrors).length > 0 || nameError !== null; + + const submit = async () => { + setTouched(true); + if (invalid || busy) return; + setBusy(true); + setServerError(null); + const withSelections = Object.keys(selections).length ? { selections } : {}; + const command: FeatureCommand = edit + ? { op: "edit", cell: edit.cell, values, ...withSelections } + : { op: "add", spec: spec.id, ...(name.trim() ? { name: name.trim() } : {}), values, ...withSelections }; + try { + await onSubmit(command); + } catch (error) { + setServerError(error instanceof Error ? error.message : String(error)); + setBusy(false); + } + }; + + const useFace = () => { + const faces = filterSelection(selection, "face"); + if (faces.length !== 1) { + setNote("Pick exactly one face in the viewport first."); + return; + } + const face = faces[0]; + if (!isDescribed(face)) { + setNote("The face details are still loading; try again in a moment."); + return; + } + const { point, direction } = holeFromFace(face); + if (!point) { + setNote("This face has no centroid; type the start point."); + return; + } + setForm((previous) => ({ + ...previous, + position: point.map((c) => String(Math.round(c * 1e4) / 1e4)), + ...(direction ? { direction } : {}), + })); + setNote( + direction + ? `Start point set from the face centroid, direction ${direction} (into the material).` + : "Start point set from the face centroid. The face reports no normal: check the direction.", + ); + setServerError(null); + }; + + const own = new Set(edit?.makes ?? []); + const bodies = bodyChoices(state).filter((body) => !own.has(body.name)); + + return ( + event.preventDefault()} + onKeyDown={(event) => { + if (event.key === "Enter" && !(event.target instanceof HTMLTextAreaElement) && !(event.target instanceof HTMLButtonElement)) { + event.preventDefault(); + void submit(); + } + }} + > + + + {editing ? "Edit" : "Add"} {spec.label.toLowerCase()} + + {spec.description} + +
+ + setName(event.target.value)} + /> + + {spec.slots.map((slot) => ( + + + {spec.id === "hole" && slot.name === "position" && ( +
+ + {note && {note}} +
+ )} +
+ ))} + {serverError && ( +

+ {serverError} +

+ )} +
+ + + + +
+ ); +} + +function Field({ + label, + unit, + help, + error, + children, +}: { + label: string; + unit?: string; + help?: string; + error?: string | null; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {help && !error && {help}} + {error && {error}} +
+ ); +} + +function SlotWidget({ + slot, + form, + set, + invalid, + bodies, + selection, + kept, + picks, +}: { + slot: Slot; + form: RawForm; + set: (slot: string, value: RawForm[string]) => void; + invalid: boolean; + bodies: { name: string; open: boolean }[]; + selection: SelectionItem[]; + kept: boolean; + picks: number; +}) { + const raw = form[slot.name]; + switch (slot.kind) { + case "bool": + return set(slot.name, checked)} />; + case "enum": + return ( + + ); + case "body": { + const open = bodies.filter((body) => body.open); + const used = bodies.filter((body) => !body.open); + return ( + + ); + } + case "point": { + const parts = Array.isArray(raw) ? raw : ["", "", ""]; + return ( +
+ {["x", "y", "z"].map((axis, index) => ( + set(slot.name, parts.map((part, i) => (i === index ? event.target.value : part)))} + /> + ))} +
+ ); + } + case "points2d": + case "points3d": + return ( +