diff --git a/AGENT.md b/AGENT.md index c707112d..538814be 100644 --- a/AGENT.md +++ b/AGENT.md @@ -29,3 +29,72 @@ BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks h # Before editing or adding code, plan for module splitting rather than putting code in one file which is not human readable. # If you have any experience during working, you can write it down here, with detailed reasons. + +## Experience: rebuilding the C++ module on this Windows machine (2026-11-10) + +Why this is written down: changing anything under `src/cpp` needs a rebuilt `_native` module, and the obvious command +failed three times before it worked. Each failure had a cause that is not visible from the error text. + +- UPDATE (2026-11-10, later): none of the flags below are needed any more. `pyproject.toml` builds with + `tools/build_backend/scad_build.py`, which on Windows picks Ninja + the gcc on PATH when Visual Studio is absent, finds + OCCT (`SCAD_OCCT_PREFIX`, `C:/occt`, or `%LOCALAPPDATA%/simplecadapi/occt-`, building it there once if missing) and + limits the job count by RAM. A bare `uv sync` / `uv run` works; anything you set yourself (CMAKE_GENERATOR, CC, CXX, + CMAKE_ARGS, CMAKE_BUILD_PARALLEL_LEVEL) still wins. `[tool.uv] cache-keys` makes uv rebuild only when `src/cpp` changes, + not on a version bump. What remains true: a running editor locks the installed `.pyd` (rename it aside, see below). +- (Older note) Use the project's own path, `uv pip install --python .venv/Scripts/python.exe -e . --no-deps -v`. It reuses + `src/cpp/build/wheel//` (incremental when the compiler flags did not change). Plain `ninja _native` in that + directory fails: its CMakeCache points into a temporary isolated build environment (ninja, pybind11, python) that was + deleted when the last build ended, so CMake cannot regenerate. +- A bare run picks the NMake generator (no `nmake` here) and then a stale OpenCASCADE from `%TEMP%\occt-build-*`. Pass + the MinGW toolchain and the real OCCT explicitly, in the same shell: + `CMAKE_GENERATOR=Ninja CC=/gcc.exe CXX=/g++.exe + CMAKE_ARGS="-DCMAKE_MAKE_PROGRAM=/ninja.exe -DSCAD_OCCT_PREFIX=C:/occt -DOpenCASCADE_DIR=C:/occt/lib/cmake/opencascade"`. + (The first full rebuild after the environment changed took about 4 minutes; the next, after one binding file, 1m16s.) +- The build ends with "failed to remove ... Scripts/sca.exe" when an editor (`sca edit`) is running: that is only the + install step. The compiled module is already at `src/cpp/build/wheel//bindings/python/_native..pyd`. + The installed copy in `.venv/lib/site-packages/simplecadapi/_cpp/` is locked by the running processes (copy fails with + "Device or resource busy") but Windows lets you RENAME a loaded DLL: rename the old `.pyd` aside, copy the new one in. + New processes load the new module, running editors keep the old one. Do not kill the user's editor to install. +- The built web UI (`src/simplecadapi/editor/webui/`) is git-ignored: after changing `editor-web/src`, rebuild it with + `editor-web/node_modules/.bin/vite.exe build`, and typecheck with `node editor-web/node_modules/typescript/bin/tsc + --noEmit -p editor-web/tsconfig.json` (`npx tsc` downloads an unrelated package named tsc). +- `design-docs/` is git-ignored but some files in it are tracked: a new file there needs `git add -f`. +- `docstrings.json` is read back over the docstring in the Python source at import time, so a docstring edit is + invisible in `help()` until `_glue/docstrings.json` is updated too (edit the one line, do not re-dump the file). + +## How to commit: one decision per commit, with the reasons (the owner's rule, 2026-11-10) + +Why this rule exists: a change that reverses an earlier design (for example "a Part holds exactly one solid") is +made of many small edits across layers. Someone who takes over later sees the edits, not the conversation that +caused them. The commit history is where that conversation has to survive, so that handing over and tracing back +are possible without asking anyone. + +1. **One commit per decision, split as finely as is sensible.** A decision is a choice that could have gone the + other way (a rule, a format, an API removal, a place where a feature lives). Edits that only follow from a + decision (tests, docs, comments, dead code) are their own commits, each stating which decision they follow. + Do not mix two decisions in one commit; do not split one decision so far that no commit holds its reason. +2. **Every message states the chain of the decision and WHY, and the why carries the core of the discussion** + between the owner and the assistant that led to it: the need that started it, the owner's judgement, the + analysis that was agreed, and what was rejected. Write it as what was said, not as a summary of the diff. The + reader must be able to see why the decision exists from the message alone. +3. **Message layout** (subject line, then paragraphs; the subject says the decision, not only the edit): + - `DECISION:` what is decided, in one or two sentences. + - `WHY` (name the discussion and its date; point to the decision record): the need, the judgement, the + analysis, and facts found by checking (with where and how they were checked). + - `ALTERNATIVE CONSIDERED AND REJECTED:` when there was one, and why. + - `WHAT CHANGES:` the files / behaviour, only as far as the reason needs; and what is kept on purpose. + - `Checked:` what was run and what it showed, including what was not verified. + - Last line: `Co-Authored-By: ...` as the session's attribution reminder says. +4. **A decision that outlives one commit gets a decision record** in `design-docs/.md` (the whole + discussion, the audit table, the rules after the decision, the list of commits). The first commit of the + series carries the full account; later commits carry the short form and name that commit or the record. + `design-docs/` is git-ignored: add a new file with `git add -f`. If an older design document is replaced, mark + it superseded at its top and keep its body. +5. **Refer to other commits by subject line, not by hash.** Hashes change when history is rewritten (rewording a + message with `filter-branch` changed every hash of the series once); a subject survives. A hash is acceptable + only for a commit that is already published or will not be rewritten. +6. **Say the true thing.** If a message claims a check passed or a set of files is equal, run the check first; if + a claim turns out wrong, `git commit --amend` while the commit is still the newest and unpublished. +7. **Mechanics that worked here**: write the message to a file with the file tool and use `git commit -F file` + (long heredocs with quotes broke in this shell); stage by explicit paths, never `git add -A` (the untracked + `STILL` file is not ours); work on a branch, not on `master`; do not push or merge unless asked. diff --git a/CHANGELOG.md b/CHANGELOG.md index aac885dd..99366250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,25 @@ All notable changes to SimpleCADAPI are documented here. Per-version user-facing notes live in [`docs/updates/`](docs/updates/) (English and 中文). -## Unreleased +## 2.2.9 — 2026-11-10 + +Replaces 2.2.8, which was withdrawn (yanked on PyPI). User-facing notes: [`docs/updates/2.2.9.md`](docs/updates/2.2.9.md). + +### Changed +- A `Part` holds exactly one solid again. The multi-body Part of the withdrawn 2.2.8 release (`make_part_rpart(bodies=...)`, + `Part.multi_body`, `scad.part_shape`, `inspect_body_overlaps_rlist`, `BodyOverlapWarning`, part definition profile + `simplecad-part-definition-3` with `solid_cache.bodies`) is removed: a Part is the unit an assembly places, mates and gives + connectors to, and a Part of several bodies could do none of that (no connectors, `explode_rassembly` raised). Several + solids that exist side by side while a model is built (a tool next to its body) are ordinary notebook values: a part + notebook with one open solid still gets the implicit Part; with several there is no product and the run raises + `SeveralOpenBodiesError` naming them. The editor draws them side by side ("N open solids") and its `use_as_product` + makes the Part from the one you choose. Several solids as a deliverable are one Part each in an Assembly. + +### Fixed +- On Windows `sca cell` no longer kills the editor it talks to: the editor pid is probed without signalling it + (`os.kill(pid, 0)` terminates the process on Windows). +- Building from source with GCC 14 (`operators_promote.cpp` includes ``) and MinGW (`_USE_MATH_DEFINES`; `build_occt.py` + picks Ninja when a MinGW `g++` is the compiler); the macOS wheels are built on `macos-15` (still targeting macOS 14.0). ## 2.2.8 — 2026-11-09 diff --git a/design-docs/multi-solid-design.md b/design-docs/multi-solid-design.md index 33e56356..9b9209f4 100644 --- a/design-docs/multi-solid-design.md +++ b/design-docs/multi-solid-design.md @@ -1,5 +1,9 @@ # Multi-body parts (several solids in one part, without an assembly) +> **SUPERSEDED on 2026-11-10 by `part-holds-one-solid.md`.** The multi-body Part described here was withdrawn: a Part +> holds exactly one solid again. Read that document for the decision, the reasons and the discussion behind it. This +> one is kept only as the record of what was tried. + Status: research and spike on `research/multi-solid` (cut from `dev`). The spike is in the code and in `test/test_multi_body_part.py`. Everything under "Staged plan" after stage 1 is a proposal. Section 9 records the owner's decisions and how they are implemented; where it differs from an earlier section, section 9 wins. diff --git a/design-docs/part-holds-one-solid.md b/design-docs/part-holds-one-solid.md new file mode 100644 index 00000000..8cee050d --- /dev/null +++ b/design-docs/part-holds-one-solid.md @@ -0,0 +1,98 @@ +# A Part holds exactly one solid (the multi-body Part is withdrawn) + +Status: decided on 2026-11-10 and implemented on branch `fix/part-holds-one-solid`. This document **supersedes** +`multi-solid-design.md`. Read it first; the older document is kept only as the record of what was tried. + +It is written so that someone who was not in the conversation can see what was decided, why, what was rejected, +and which commit carries which part. The commit messages of the series repeat the reasons in short. + +## 1. What was decided + +**A `Part` is one solid. Nothing else is a Part.** + +- Several solids that exist side by side while a model is built (a tool and its target, the tools of a pattern before + the cut) are **loose values of a notebook**. The editor and the preview show them; they are not a product item, so + nothing places, mates, exports or weighs them as one thing. +- A deliverable that really is several solids is an **Assembly of Parts**: one Part per solid, identity placements. +- The Part constructor refuses anything but one solid, in the lowest layer (C++), so that no consumer has to branch. + +## 2. The discussion it comes from (owner and assistant, 2026-11-10) + +This is the reasoning that matters; keep it when the code changes. + +**The need that created the multi-body Part (the owner).** The editor must support several solids existing in one +notebook, with no assembly constraint between them. This is easy to understand: to pattern a tool and then cut, there +is necessarily a moment where more than one tool and the body exist together. At the time a Part was the only product +value a notebook could end in, so the Part was made to hold several named bodies. + +**The owner's judgement.** That choice makes assembly hard. The whole design was made for a human-friendly editor, +and it led to a very wrong decision: designing a multi-solid Part. The owner asked whether we should go back to +"a Part has exactly one solid" and support the human-friendly editor by some other, better means. + +**The assistant's analysis, accepted by the owner.** + +1. The solids side by side in the pattern-then-cut example are *intermediate values of a notebook*, not product + items. A Part is a line of the bill of materials and the unit an Assembly places, mates and gives connectors to. + "The working set of solids while modelling" and "the product" are two levels that were mixed into one type. +2. So the editor's need is a *preview / notebook-value* need (show every open solid), not a Part need. The runtime + already had the pieces: `cut_rsolid` returns `Pieces`, a pattern returns a solid list, and the scene had a + shape-root path. +3. The honest exit from "several solids" to a deliverable is an Assembly (`split_rpart` in the old design was + exactly that exit). +4. Keeping multi-body and finishing connectors (a connector anchored by `body_id` + entity) was costed and rejected: + it changes the part-definition contract, the interface / binding hashes and the entity bindings, and still leaves + a type that overlaps with Assembly ("an assembly without constraints"). + +**Urgency (the owner).** The multi-body Part might be a malignant bug of the released 2.2.8, so the audit and the +change were to start at once, and the GitHub release `v2.2.8` was deleted (the tag `v2.2.8` still exists on origin; +a PyPI upload, if there was one, is not undone by that). + +**How to work (the owner).** One commit per decision, and every commit message states the chain of the decision and +its reasons, so that anyone can take over and trace it later. Split decisions as finely as sensible. + +## 3. What the audit found in 2.2.8 (code read and run, 2026-11-10) + +| Finding | Where | Effect | +|---|---|---| +| Connectors on a multi-body Part are refused | `Part` constructor (`part.cpp`) | no mate, public connector or `ConnectorRef` can reach one; it joins an assembly only as a rigid blob placed by its origin | +| `explode_rassembly` raises `TypeError` | `ops_explode.cpp` `collect_box` calls `Part::body()` | any assembly holding a multi-body Part cannot be exploded | +| the scene compiler reads `part.body` | `scene/compiler.py` (4 places) | `compile_scene` fails on a multi-body Part | +| `build/definition.py` passed `part.body` to the connector interface | `definition.py` | would break the moment connectors were allowed | + +What still worked: a multi-body Part as a component with a placement, flattening an assembly to a compound, the +preview, STEP, mass, the package and the cell cache. Not checked: joint / constraint solving, interference, MJCF +(they depend on connectors, which a multi-body Part could never have). + +One-body Parts were not affected by any of this. + +## 4. The design after the decision + +| Concern | Rule | +|---|---| +| Part | exactly one solid; `Part.body` always works; `Part.bodies` is a one-element tuple kept because `Body` carries the display name, colour and opacity (`set_body_appearance_rpart`) | +| `make_part_rpart` | `body=` a Solid, a `Body`, or a list of exactly one piece; `bodies=` does not exist; several pieces raise `ValueError` that says what to do | +| Part definition (durable) | profile 2, one solid; profile 3 and `solid_cache.bodies` are removed (they only ever shipped in the withdrawn 2.2.8) | +| Notebook with several open solids | no product is chosen for the author: `find_product` raises `SeveralOpenBodiesError` naming the solids and what to do | +| Preview of such a notebook | the open solids are drawn side by side (`compile_open_bodies`, node ids `node//body:`), so the editor keeps what it needed | +| Several solids as a deliverable | an Assembly of Parts | +| `inspect_body_overlaps_rlist`, `BodyOverlapWarning`, `part_shape`, `part_from_bodies` | removed: they only existed for a Part of several bodies | +| `measure_part_mass_rmassreport` | kept; one row per Part | + +`Pieces` (the result of a splitting `cut_rsolid`, and fillet / chamfer / per-piece operations) is unaffected: it is a +notebook value, which is where several solids belong. + +## 5. The commits of the series (decision by decision) + +Each line is one decision; the commit message holds its reasons. + +1. `part: a Part holds exactly one solid` - the invariant, in C++ and the bindings. +2. `python glue + replay: follow the one-solid Part` - pickling through `Body`; replay refuses a recorded multi-solid Part. +3. `artifacts: a part definition holds one solid` - drop profile 3 and `solid_cache.bodies`. +4. further commits (preview of open solids, runtime product rule, removal of the helpers, scene / exporters, editor, + tests, docs) are listed in `git log fix/part-holds-one-solid` after the commit that adds this file. + +## 6. Open points + +- Whether anything outside this repository (a PyPI upload of 2.2.8, the editor plugins) must be withdrawn or + re-released is a release decision, not made here. +- The tag `v2.2.8` was left in place; moving or deleting it is the owner's call. diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 4708a6df..2d26981c 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.8 + version: 2.2.9 package-name: simplecadapi - package-version: 2.2.8 + package-version: 2.2.9 --- # SimpleCAD SDK Skill diff --git a/docs/skill/references/SDK_OVERVIEW.md b/docs/skill/references/SDK_OVERVIEW.md index b9eb6971..bc93c570 100644 --- a/docs/skill/references/SDK_OVERVIEW.md +++ b/docs/skill/references/SDK_OVERVIEW.md @@ -1,8 +1,8 @@ # SDK Overview - Project: `simplecadapi` -- Version: `2.2.8` -- Package distribution: `simplecadapi==2.2.8` +- Version: `2.2.9` +- Package distribution: `simplecadapi==2.2.9` ## What this skill bundles diff --git a/docs/skill/references/discipline/core-rules.md b/docs/skill/references/discipline/core-rules.md index d30545d0..c005d5ab 100644 --- a/docs/skill/references/discipline/core-rules.md +++ b/docs/skill/references/discipline/core-rules.md @@ -15,7 +15,9 @@ the durable export call `capture(result, path)`, whose two required arguments ar 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. +before writing any cell. The one exception is a task that itself asks for a single script +(`python model.py --output part.step`, an unattended batch): that is a plain script, see "One-shot script" in +`domains/export-and-translation.md`; the notebook rules do not apply to it. **R4. Sketch first.** 2D profiles and planar paths go through the sketch API; primitives only when the shape is entirely the basic form. diff --git a/docs/skill/references/discipline/failure-and-repair.md b/docs/skill/references/discipline/failure-and-repair.md index 015e68bd..5b523493 100644 --- a/docs/skill/references/discipline/failure-and-repair.md +++ b/docs/skill/references/discipline/failure-and-repair.md @@ -60,9 +60,8 @@ which claims cannot be made (see "Reporting failed repairs"). Interactive: the r `union_rsolid` requires a one-manifold-`Solid` result and fails if it cannot produce one. If the design is one body, use a contact or overlap that is topologically capable of producing one solid, then recompute. If the design is -actually multiple parts, the answer is an assembly, not a union; if it is one -part whose bodies stay apart, make a multi-body part -(`make_part_rpart(bodies=...)`). +actually multiple parts, the answer is an assembly, not a union: a Part holds +exactly one solid, so make one Part per solid and put them in an Assembly. ### Fillet/chamfer failure diff --git a/docs/skill/references/discipline/mechanical-modeling.md b/docs/skill/references/discipline/mechanical-modeling.md index 0bbc491a..e4d464ee 100644 --- a/docs/skill/references/discipline/mechanical-modeling.md +++ b/docs/skill/references/discipline/mechanical-modeling.md @@ -22,9 +22,10 @@ This document separates SDK contracts from mechanical design guidance. - **Decide part vs assembly before modeling.** Bodies that are separately manufactured, purchased, or movable belong in an assembly with connectors and placements; monolithic manufacturing intent gets a single fused solid. - Several bodies of one part are deliberate: name them in - `make_part_rpart(bodies={...})`. Never leave stray solids out of the product, - or overlapping bodies, where one labeled product was intended. + A Part holds exactly one solid: several solids that belong together are one + Part per solid in an Assembly (identity placements if they only sit side by + side). Never leave stray solids (tools, construction) out of the product where + one labeled product was intended. - **Pick the origin and orientation from the functional datum** — the mating interface, mounting plane, or symmetry axis — not from wherever the first primitive happened to land. See `datums-and-coordinate-systems.md`. diff --git a/docs/skill/references/discipline/notebook-cell-design.md b/docs/skill/references/discipline/notebook-cell-design.md index 5db26d79..e58c5271 100644 --- a/docs/skill/references/discipline/notebook-cell-design.md +++ b/docs/skill/references/discipline/notebook-cell-design.md @@ -86,15 +86,18 @@ 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. -**C9. A part notebook (`kind = "part"`) needs no product cell: the runtime -makes the Part from its open bodies** (each public top-level solid no other cell -reads becomes a body named by its variable; the pieces of a split cut become -`_1..n`). Write the product cell (last, id `scad.notebook_id()`) only for -connectors, a material, or to choose and name the bodies yourself: -`make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid})`; -solids left out of it (tools, construction) are not part of the product. An -assembly notebook ends with its layout cell. A family of parts uses -`scad.use(path, id=...)`. +**C9. A part notebook (`kind = "part"`) needs no product cell when it ends with +one open solid: the runtime makes the Part from it** (the one public top-level +solid no other cell reads). A Part holds exactly one solid. When several solids +are open at the end (a tool left next to its body, the pieces of a split cut) and +no cell makes the Part, there is no product: the run raises +`SeveralOpenBodiesError` naming them, and the editor draws them side by side +("N open solids"). Choose the one that is the part: write the product cell (last, +id `scad.notebook_id()`), also needed for connectors or a material: +`make_part_rpart(part_id=scad.notebook_id(), body=base)`; solids left out of it +(tools, construction) are not part of the product. Solids that are separate parts +are one Part each in an Assembly. An assembly notebook ends with its layout cell. +A family of parts uses `scad.use(path, id=...)`. **C10. In an assembly notebook, one `scad.use(...)` per child, the mates in their own cell, the solve in its own cell.** Do not carry the whole assembly diff --git a/docs/skill/references/docs/api/Body.md b/docs/skill/references/docs/api/Body.md index 288851b6..2fd2122a 100644 --- a/docs/skill/references/docs/api/Body.md +++ b/docs/skill/references/docs/api/Body.md @@ -14,10 +14,10 @@ class Body(body_id, solid, name=None, color=None, opacity=None) ## Description -One named solid of a Part. +The solid of a Part, with its display name, colour and opacity. ``Body(body_id, solid, name=None, color=None, opacity=None)``: ``body_id`` is an -identifier unique within the part, ``solid`` the body's Solid, ``name`` an optional +identifier (``"body"`` for the body of a Part), ``solid`` the body's Solid, ``name`` an optional display name, ``color`` an optional display colour (RGB floats in [0, 1]) and ``opacity`` an optional display opacity in [0, 1]. A one-body Part has one Body with the id ``"body"``. The colour overrides the Part's material colour for display only; diff --git a/docs/skill/references/docs/api/Part.md b/docs/skill/references/docs/api/Part.md index 52b6bc33..140f4057 100644 --- a/docs/skill/references/docs/api/Part.md +++ b/docs/skill/references/docs/api/Part.md @@ -3,7 +3,7 @@ ## Class Definition ```python -class Part(part_id, body=None, name=None, material=None, connectors=(), bodies=None) +class Part(part_id, body, name=None, material=None, connectors=()) ``` *Source: product/part.py* @@ -14,11 +14,11 @@ class Part(part_id, body=None, name=None, material=None, connectors=(), bodies=N ## Description -Product item holding one Solid (``body``) or several named bodies (``bodies``). +Product item holding exactly one Solid (``body``). -A one-body Part is the common case: ``part.body`` is its Solid. A multi-body -Part keeps several solids apart inside one product item (a case and its lid -modelled side by side, the pieces of a split cut): ``part.bodies`` lists them -as ``Body`` values in order, ``part.get_body(body_id)`` finds one, and -``part.body`` raises ``TypeError``. Bodies of one Part are never placed -relative to each other and carry no joints; use an Assembly for that. +A Part is the unit an Assembly places, mates and gives connectors to, so it is one +solid. ``part.body`` is its Solid; ``part.bodies`` is that body as a one-element tuple +of ``Body`` values (a ``Body`` carries the display name, colour and opacity, +``set_body_appearance_rpart``). ``body`` also takes a ``Body``. Several solids are +refused (ValueError): make one Part per solid in an Assembly, or union / cut them +into one solid first. diff --git a/docs/skill/references/docs/api/README.md b/docs/skill/references/docs/api/README.md index d0f2c5fe..209fb8f6 100644 --- a/docs/skill/references/docs/api/README.md +++ b/docs/skill/references/docs/api/README.md @@ -416,7 +416,6 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [insert_surface_knot_rface](insert_surface_knot_rface.md) *(from operators/geometry.py)* `top-level` - [inset_face_rsolid](inset_face_rsolid.md) *(from operators/features.py)* `top-level` - [inspect_assembly_constraints_rconstraintreport](inspect_assembly_constraints_rconstraintreport.md) *(from operators/product.py)* `top-level` -- [inspect_body_overlaps_rlist](inspect_body_overlaps_rlist.md) *(from operators/product.py)* `top-level` - [inspect_curve_rcurvedata](inspect_curve_rcurvedata.md) *(from operators/geometry.py)* `top-level` - [inspect_sketch_rsketchcrossings](inspect_sketch_rsketchcrossings.md) *(from operators/sketch.py)* `top-level` - [inspect_sketch_rsketchprofiles](inspect_sketch_rsketchprofiles.md) *(from operators/sketch.py)* `top-level` diff --git a/docs/skill/references/docs/api/apply_tag_rselection.md b/docs/skill/references/docs/api/apply_tag_rselection.md index c817661a..859c896e 100644 --- a/docs/skill/references/docs/api/apply_tag_rselection.md +++ b/docs/skill/references/docs/api/apply_tag_rselection.md @@ -24,7 +24,7 @@ operation with the picked faces as ``targets``. ``block = scad.apply_tag_rselection(block, scad.ql.faces().where(scad.ql.prop("geom.normal.z", ">=", 0.999)), "lid_seat")`` names the top face; ``scad.ql.faces().where(scad.ql.tag("lid_seat"))`` selects it -later. A body of a Part is named with ``set_body_appearance_rpart(part, body_id, name=...)``. +later. The body of a Part is named with ``set_body_appearance_rpart(part, name=...)``. ## Parameters diff --git a/docs/skill/references/docs/api/cut_rsolid.md b/docs/skill/references/docs/api/cut_rsolid.md index 0c63a0d7..0a863726 100644 --- a/docs/skill/references/docs/api/cut_rsolid.md +++ b/docs/skill/references/docs/api/cut_rsolid.md @@ -3,7 +3,7 @@ ## API Definition ```python -def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces') +def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces', clean=True) ``` *Source: operators/boolean.py* @@ -33,6 +33,10 @@ sequences, and returns a single `Solid`. - **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta. +### clean + +- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ### on_split - **Description**: What a cut that splits the body (a slot through a bar) returns. @@ -52,8 +56,8 @@ Solid: The cut result solid, when the result is one piece. list[Solid]: The pieces (a ``scad.Pieces``), when the cut split the body (``on_split="pieces"``). Operations that take one shape run on every piece (``cut_rsolid(pieces, tool)``, ``translate_shape(pieces, ...)``) and return -``Pieces``; a selector's cardinality is checked over all pieces. Pass them on as bodies: ``make_part_rpart(part_id=..., body=pieces)`` makes the -bodies ``body_1 .. body_n``, ``bodies={"bar": pieces}`` makes ``bar_1 .. bar_n``. +``Pieces``; a selector's cardinality is checked over all pieces. They are notebook values: a Part holds exactly one solid, so make one Part of each piece +(an Assembly of them is the several-piece result), not ``make_part_rpart(body=pieces)``. ## Raises diff --git a/docs/skill/references/docs/api/cut_rsolid_list.md b/docs/skill/references/docs/api/cut_rsolid_list.md index 930d17f1..e3e54020 100644 --- a/docs/skill/references/docs/api/cut_rsolid_list.md +++ b/docs/skill/references/docs/api/cut_rsolid_list.md @@ -3,7 +3,7 @@ ## API Definition ```python -def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full') +def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full', clean=True) ``` *Source: operators/boolean.py* @@ -39,6 +39,10 @@ a through slot. Use ``cut_rsolid`` when the result must be one solid. - **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta. +### clean + +- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ## Returns list[Solid]: Every piece of the cut, in kernel order; empty when the tools diff --git a/docs/skill/references/docs/api/export_product_package_to_mjcf.md b/docs/skill/references/docs/api/export_product_package_to_mjcf.md index a41c05fd..2058ec01 100644 --- a/docs/skill/references/docs/api/export_product_package_to_mjcf.md +++ b/docs/skill/references/docs/api/export_product_package_to_mjcf.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_mjcf(data: ProductPackageInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport +def export_product_package_to_mjcf(data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport ``` *Source: exporter/mjcf.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_obj.md b/docs/skill/references/docs/api/export_product_package_to_obj.md index 4e84a492..1c82c85b 100644 --- a/docs/skill/references/docs/api/export_product_package_to_obj.md +++ b/docs/skill/references/docs/api/export_product_package_to_obj.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_obj(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport +def export_product_package_to_obj(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport ``` *Source: exporter/obj.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_step.md b/docs/skill/references/docs/api/export_product_package_to_step.md index 6882ff1a..235d85f1 100644 --- a/docs/skill/references/docs/api/export_product_package_to_step.md +++ b/docs/skill/references/docs/api/export_product_package_to_step.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_step(data: ProductPackageInput, output_path: str | Path) -> ProductSTEPExportReport +def export_product_package_to_step(data: ProductInput, output_path: str | Path) -> ProductSTEPExportReport ``` *Source: exporter/step.py* @@ -14,4 +14,5 @@ def export_product_package_to_step(data: ProductPackageInput, output_path: str | ## Description -Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP. +Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of +a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene. diff --git a/docs/skill/references/docs/api/export_product_package_to_stl.md b/docs/skill/references/docs/api/export_product_package_to_stl.md index 63c9f908..31d8e512 100644 --- a/docs/skill/references/docs/api/export_product_package_to_stl.md +++ b/docs/skill/references/docs/api/export_product_package_to_stl.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_stl(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport +def export_product_package_to_stl(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport ``` *Source: exporter/stl.py* diff --git a/docs/skill/references/docs/api/inspect_body_overlaps_rlist.md b/docs/skill/references/docs/api/inspect_body_overlaps_rlist.md deleted file mode 100644 index d7b4f906..00000000 --- a/docs/skill/references/docs/api/inspect_body_overlaps_rlist.md +++ /dev/null @@ -1,43 +0,0 @@ -# inspect_body_overlaps_rlist - -## API Definition - -```python -def inspect_body_overlaps_rlist(part: 'Part', *, min_volume_mm3: 'float' = 1e-06) -> 'list[BodyOverlap]' -``` - -*Source: operators/product.py* - -## Import Surface - -- top-level: `from simplecadapi import inspect_body_overlaps_rlist` - -## Description - -List the pairs of bodies of a Part that share volume. - -Overlapping bodies are allowed (a Part does not fuse them), but mass counts the -shared volume once per body and STEP holds both copies. Bodies that only touch -(a face, an edge or a point in common) do not overlap. - -``scad.inspect_body_overlaps_rlist(case)`` before export. Building the part's -definition (a notebook run, ``@part``, a package) warns with ``BodyOverlapWarning`` -for the same pairs. - -## Parameters - -### part - -- **Description**: The Part to inspect; a one-body Part has no overlaps. - -### min_volume_mm3 - -- **Description**: Shared volume below which a pair is not reported. - -## Returns - -list[BodyOverlap]: ``(first, second, volume_mm3)`` per overlapping pair, in body order. - -## Raises - -- **SimpleCADError**: The value is not a Part. diff --git a/docs/skill/references/docs/api/intersect_rsolid.md b/docs/skill/references/docs/api/intersect_rsolid.md index 023b0b3e..26ce58b7 100644 --- a/docs/skill/references/docs/api/intersect_rsolid.md +++ b/docs/skill/references/docs/api/intersect_rsolid.md @@ -3,7 +3,7 @@ ## API Definition ```python -def intersect_rsolid(*solids) +def intersect_rsolid(*solids, clean=True) ``` *Source: operators/boolean.py* @@ -26,6 +26,10 @@ error instead of returning an empty list. - **Description**: One or more Solid objects or sequences of Solid. Nested sequences are flattened before processing. +### clean + +- **Description**: When True (the default) the faces and edges the intersection leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ## Returns Solid: The overlap region as a single solid. diff --git a/docs/skill/references/docs/api/make_part_rpart.md b/docs/skill/references/docs/api/make_part_rpart.md index 719955ec..87580b8f 100644 --- a/docs/skill/references/docs/api/make_part_rpart.md +++ b/docs/skill/references/docs/api/make_part_rpart.md @@ -3,7 +3,7 @@ ## API Definition ```python -def make_part_rpart(part_id, body=None, name=None, bodies=None) +def make_part_rpart(part_id, body, name=None) ``` *Source: operators/product.py* @@ -14,13 +14,13 @@ def make_part_rpart(part_id, body=None, name=None, bodies=None) ## Description -Wrap one Solid, or several named solids, as a semantic Part. +Wrap one Solid as a semantic Part. A Part holds exactly one solid. -``scad.make_part_rpart(part_id=scad.notebook_id(), body=bracket)`` for one solid; -``scad.make_part_rpart(part_id=scad.notebook_id(), bodies={"case": case, "lid": lid})`` -for a case and its lid kept as two bodies of one part. Material applies to every -body. Connectors are not supported on multi-body parts yet. Union the solids -first when they must become one body. +``scad.make_part_rpart(part_id=scad.notebook_id(), body=bracket)``. Several solids +are not one Part: make one Part per solid and put them in an Assembly (identity +placements when they only sit together), or union / cut them into one solid first. +Solids that sit side by side while a model is built (a tool next to the body it will +cut) are ordinary notebook values, not a Part. ## Parameters @@ -30,25 +30,17 @@ first when they must become one body. ### body -- **Description**: The Solid of a one-body Part. The pieces of a split cut (a list of Solids) become the bodies ``body_1 .. body_n``. +- **Description**: The Solid of the Part. A ``Body`` (a solid with a display name, colour and opacity) and a list of exactly one piece of a split cut are also taken. Several solids, or a list of several pieces, are refused. ### name - **Description**: Optional display name. -### bodies - -- **Description**: Instead of ``body``: several solids kept apart in one Part, as a mapping ``body_id -> Solid`` (in its order) or a sequence of ``Body`` values. A mapping value may be the pieces of a split cut (a list of - -### Solids) - -- **Description**: they become ``_1 .. _n``. Body ids are identifiers, unique within the part. Overlapping bodies are allowed; building the definition warns (``BodyOverlapWarning``). - ## Returns -Part: The new Part. ``part.bodies`` lists its bodies; ``part.body`` is the -Solid of a one-body Part. +Part: The new Part. ``part.body`` is its Solid; ``part.bodies`` is that body as a +one-element tuple. ## Raises -- **SimpleCADError**: Neither or both of ``body`` and ``bodies`` were given, a body is not a Solid, a body id is malformed or repeated, or the part_id is already used. +- **SimpleCADError**: The body is not a Solid, more than one solid was given (a ValueError that says to make one Part per solid in an Assembly, or to union / cut them to one solid), or the part_id is already used. diff --git a/docs/skill/references/docs/api/measure_part_mass_rmassreport.md b/docs/skill/references/docs/api/measure_part_mass_rmassreport.md index 5068c3fa..14440062 100644 --- a/docs/skill/references/docs/api/measure_part_mass_rmassreport.md +++ b/docs/skill/references/docs/api/measure_part_mass_rmassreport.md @@ -14,17 +14,16 @@ def measure_part_mass_rmassreport(part: 'Part', *, density: 'float | None' = Non ## Description -Measure the volume, mass and centre of mass of every body of a Part, and of the Part as a whole. +Measure the volume, mass and centre of mass of a Part. -``scad.measure_part_mass_rmassreport(case).body("lid").mass_kg`` is the mass of -the lid body; ``.mass_kg`` the mass of the whole part. Every body has the -Part's material. +``scad.measure_part_mass_rmassreport(bracket).mass_kg`` is the mass of the part. +The part's material gives the density unless ``density`` is passed. ## Parameters ### part -- **Description**: The Part to measure (one body or several). +- **Description**: The Part to measure. ### density @@ -36,9 +35,9 @@ Part's material. ## Returns -MassReport: ``bodies`` (one ``BodyMass`` per body, in body order: ``body_id``, -``volume_mm3``, ``mass_kg``, ``centroid_mm``) and the totals of the part -(``volume_mm3``, ``mass_kg``, volume-weighted ``centroid_mm``). +MassReport: ``bodies`` (the Part's one ``BodyMass``: ``body_id``, ``volume_mm3``, +``mass_kg``, ``centroid_mm``) and the totals of the part (``volume_mm3``, +``mass_kg``, ``centroid_mm``). ## Raises diff --git a/docs/skill/references/docs/api/ref.face.md b/docs/skill/references/docs/api/ref.face.md index 004c4f94..a40c28b7 100644 --- a/docs/skill/references/docs/api/ref.face.md +++ b/docs/skill/references/docs/api/ref.face.md @@ -44,7 +44,7 @@ A reference to a face born in *feature* by *op*. ### slot -- **Description**: The output of that operation (a multi-body result has one per body). +- **Description**: The output of that operation (an operation that makes several solids has one per solid). ### piece diff --git a/docs/skill/references/docs/api/set_body_appearance_rpart.md b/docs/skill/references/docs/api/set_body_appearance_rpart.md index 73a73cbd..10708b10 100644 --- a/docs/skill/references/docs/api/set_body_appearance_rpart.md +++ b/docs/skill/references/docs/api/set_body_appearance_rpart.md @@ -14,7 +14,7 @@ def set_body_appearance_rpart(part, body_id='body', *, color=None, opacity=None, ## Description -Set the display colour, opacity and display name of one body of a Part. +Set the display colour, opacity and display name of the body of a Part. A Part is a value: the result is a new Part, the input is unchanged. The colour is an appearance only. A body without a colour shows its Part's material colour @@ -22,21 +22,20 @@ appearance only. A body without a colour shows its Part's material colour overrides the material colour for display (the live preview, the package scene, the STEP surface colour of that solid). Mass, density and geometry never read it. -``case = scad.set_body_appearance_rpart(case, "lid", color=(0.9, 0.3, 0.2), name="Lid")`` -paints the lid body of a multi-body part; ``scad.set_body_appearance_rpart(part, -color=(0.2, 0.4, 0.8))`` paints a one-body part. Recorded and replayed like -``assign_material_rpart``. To name a face or an edge for people, tag it +``lid = scad.set_body_appearance_rpart(lid, color=(0.9, 0.3, 0.2), name="Lid")`` +paints the part's body (a Part holds one solid, whose body id is ``"body"``). Recorded and +replayed like ``assign_material_rpart``. To name a face or an edge for people, tag it (``scad.apply_tag_rselection`` / ``ql.tag``): a tag is the name. ## Parameters ### part -- **Description**: The Part (one body or several). +- **Description**: The Part. ### body_id -- **Description**: The body to change: an id from ``part.body_ids()``. A one-body Part has the one body ``"body"`` (the default). +- **Description**: The body to change: the Part's one body, ``"body"`` (the default). ### color @@ -56,7 +55,7 @@ color=(0.2, 0.4, 0.8))`` paints a one-body part. Recorded and replayed like ## Returns -Part: The Part with that body's appearance changed; ``part.get_body(body_id).color``, +Part: The Part with the body's appearance changed; ``part.get_body(body_id).color``, ``.opacity`` and ``.name`` read it back. ## Raises diff --git a/docs/skill/references/docs/core/compound.md b/docs/skill/references/docs/core/compound.md index f7a5c000..74ca6c1a 100644 --- a/docs/skill/references/docs/core/compound.md +++ b/docs/skill/references/docs/core/compound.md @@ -19,7 +19,6 @@ Use `Compound` when a workflow intentionally needs a flattened geometry projection. Do not use it as a substitute for `Assembly` product structure. For normal part modeling, keep using `Solid` as the body-level geometry. A -`Part` wraps one `Solid`, or several named bodies (`Part.bodies`), each its own -`Solid`. A `Compound` built from solids does not keep their tags, so it is not -a way to hold the bodies of a part (`scad.part_shape(part)` gives that view for -geometry-only use). +`Part` wraps exactly one `Solid`. A `Compound` built from solids does not keep +their tags, so it is not a way to hold several solids as one Part: make one Part +per solid and an Assembly of them. diff --git a/docs/skill/references/docs/core/operation_graph_json_spec.md b/docs/skill/references/docs/core/operation_graph_json_spec.md index b0936824..f703e88f 100644 --- a/docs/skill/references/docs/core/operation_graph_json_spec.md +++ b/docs/skill/references/docs/core/operation_graph_json_spec.md @@ -1567,12 +1567,14 @@ Notes: | --- | --- | --- | | `tool_count` | `int` | number of subtractive tool solids | | `skip_non_intersecting` | `bool` | whether non-intersecting tools are skipped | +| `clean` | `bool` | whether the faces and edges the cut leaves split were unified | Notes: - Public `cut_rsolid(...)` defaults this flag to `true` for interactive convenience. - Graph replay defaults missing `skip_non_intersecting` to `false` so malformed or drifting graphs fail diagnostically instead of silently skipping tools. - Multi-tool cut preserves the full topology delta chain across all performed tool cuts. +- `clean` has no replay default: a graph without it is rejected like one without `tool_count`. #### `make_intersect_rsolid` @@ -1583,6 +1585,7 @@ Notes: | Key | Type | Meaning | | --- | --- | --- | | `input_count` | `int` | number of intersected solids | +| `clean` | `bool` | whether the faces and edges the intersection leaves split were unified | Notes: diff --git a/docs/skill/references/docs/core/part_assembly_development_plan.md b/docs/skill/references/docs/core/part_assembly_development_plan.md index 82e0bcce..d589030c 100644 --- a/docs/skill/references/docs/core/part_assembly_development_plan.md +++ b/docs/skill/references/docs/core/part_assembly_development_plan.md @@ -32,7 +32,7 @@ code. If the remaining work is inside the Part/Assembly MVP lake, boil it in the same implementation pass. The ocean boundary is different: full assembly mate solving, full physical -simulation, multi-body part authoring, editable feature trees, or rewriting the +simulation, editable feature trees, or rewriting the kernel around XCAF/OCAF are out of scope for this MVP and should be handled as separate future lakes. diff --git a/docs/skill/references/docs/guides/notebook-runtime.md b/docs/skill/references/docs/guides/notebook-runtime.md index 5559ec39..c5d1f6bf 100644 --- a/docs/skill/references/docs/guides/notebook-runtime.md +++ b/docs/skill/references/docs/guides/notebook-runtime.md @@ -86,10 +86,11 @@ if __name__ == "__main__": that no other cell reads and that no other such value was built from; a cell that only makes construction geometry -- workplanes, wires, edges, e.g. a path through the body's vertices -- does not count as reading it), with - the notebook id. One open body is a one-body Part (body id `"body"`); several - are the bodies of one Part, named by their variables (pieces `_1..n`). - Write a `make_part_rpart` cell only to add connectors or a material, or to - choose and name the bodies yourself; it then wins. The headless run and the + the notebook id. One open body is the Part (body id `"body"`). A Part holds + exactly one solid, so with several open bodies the runtime does not choose: + see the bullet "A Part holds exactly one solid" below. + Write a `make_part_rpart` cell to add connectors or a material, or to + choose the solid that is the part; it then wins. The headless run and the editor use the same rule (`runtime/projection.py`, `find_product`). A notebook with no Part, Assembly or solid yet (a new one, or only sketches, curves and workplanes so far) raises `NoProductYetError` (a @@ -105,12 +106,15 @@ if __name__ == "__main__": (`run_notebook(..., id=...)`, `scad.use(..., id=...)`, `sca run --id`); cells read the id of their run with `scad.notebook_id()` — see [Part families](notebook-composition-and-cache.md#part-families). -- **A part with several bodies** is one product: - `scad.make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid})`. - Each body keeps its own BRep, topology snapshot and tags in the package, whose - part definition then has profile `simplecad-part-definition-3` (an older SDK - refuses it instead of reading one body). One-body parts are unchanged. - The cell cache restores a multi-body part like any other value. +- **A Part holds exactly one solid.** A part notebook (`kind = "part"`) that ends + with one open solid needs no product cell. With several open solids (a tool + left next to the body it cut) and no cell making the Part, `find_product` raises + `SeveralOpenBodiesError` naming them (`sca run` and export stop with it; the + editor draws them side by side, "N open solids", and its `use_as_product` makes + the Part from the one you choose). Make the Part from the solid that is the part, + `scad.make_part_rpart(part_id=scad.notebook_id(), body=base)`, or make one Part + per solid and an Assembly of them. The part definition is always profile + `simplecad-part-definition-2` with one solid. - **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 diff --git a/docs/skill/references/domains/assembly-and-product.md b/docs/skill/references/domains/assembly-and-product.md index 1455b8f9..31c4919c 100644 --- a/docs/skill/references/domains/assembly-and-product.md +++ b/docs/skill/references/domains/assembly-and-product.md @@ -22,16 +22,15 @@ deliver `.scadpkg` packages. - Monolithic single-body manufacturing intent: fuse with `union_rsolid` in `domains/part-modeling.md`. -- Several bodies of one part that never move apart and are one BOM item - (a multi-body part, `make_part_rpart(bodies=...)`): `domains/part-modeling.md`. - Export or translation decisions: they branch from the package in `domains/export-and-translation.md`. ## Structure model -- `Part` wraps one `Solid` (or several named bodies, `bodies=`) plus identity - and optional material (`make_part_rpart`, `assign_material_rpart`, - `make_material_rmaterial`). In an assembly a multi-body part is one component; - its bodies are never placed or jointed separately. +- `Part` wraps exactly one `Solid` plus identity and optional material + (`make_part_rpart`, `assign_material_rpart`, `make_material_rmaterial`). Every + Part can be placed, mated and given connectors because it is one solid; several + solids that belong together are one Part each, as components of an Assembly + (identity placements when they only sit side by side). - Display colour: `Material.color` is the default for every body of a part; `set_body_appearance_rpart(part, body_id, color=..., opacity=..., name=...)` overrides it per body for display (preview, package scene node diff --git a/docs/skill/references/domains/editor-gui-features.md b/docs/skill/references/domains/editor-gui-features.md index 057f2161..f48e2608 100644 --- a/docs/skill/references/domains/editor-gui-features.md +++ b/docs/skill/references/domains/editor-gui-features.md @@ -41,10 +41,11 @@ HOLE_DIAMETER = scad.var(name="hole_diameter", default=6.0, unit="mm", comment=" (`_x_by_tag`, `_x_by_geometry`) still runs and its form keeps the block; a pick the kernel cannot name (a body without recorded history, `sca feature add` with geometry only) is still written that way. - A new part notebook (`sca new NAME`, or the editor's "new") has no cells and a `kind = "part"` header: no product cell is written, - the runtime makes the Part from the open bodies (see `docs/guides/notebook-runtime.md`; a solid made with "New body" - is one more body of the same Part, not an assembly), so every new feature previews - and a deleted cell cannot leave the notebook without a product. Setting connectors (or "use as product" on a body - that is not the last) writes an explicit `make_part_rpart` cell, which then wins. + the runtime makes the Part from the one open body (see `docs/guides/notebook-runtime.md`), so every new feature previews + and a deleted cell cannot leave the notebook without a product. A solid made with "New body" stays beside the + first one as a loose solid (the header says "N open solids"); a Part holds exactly one solid, so with several open + solids there is no product until you choose: "use as product" on a body writes an explicit `make_part_rpart` cell + for it. Setting connectors (or "use as product" on a body that is not the last) also writes that cell, which then wins. - Besides the curated features (box, hole, pull face, extrude of a sketch ...), every SDK and standard-library operation the forms can offer is a feature of its own, `op.` (`# gui: {"spec": "op.make_spur_gear_rsolid", ...}`): the form is made from the operation's signature (`sca ops NAME`), and the cell is one plain call diff --git a/docs/skill/references/domains/export-and-translation.md b/docs/skill/references/domains/export-and-translation.md index 1e72bbea..6b9d3242 100644 --- a/docs/skill/references/domains/export-and-translation.md +++ b/docs/skill/references/domains/export-and-translation.md @@ -60,8 +60,10 @@ Read the exact page under `references/docs/api/` for every API used: ## Export rules -- Translate only from a validated `.scadpkg` package: `capture` the product - first, then export from the package path. +- Translate only from a validated product package: `capture` the product + first, then export from the package path. In a one-shot script (below) the + exporters also take the build result itself and package it in memory, with + the same validation and without the scene projection nobody reads. - STL and OBJ share one direct OpenCASCADE tessellation of the evaluated BREP; both contain the same oriented triangles. Control curved-surface accuracy with `linear_deflection` (chordal deviation in product units) and @@ -75,6 +77,38 @@ Read the exact page under `references/docs/api/` for every API used: - Use `validate_step_roundtrip_rdescriptor` when a STEP export must survive re-import (roundtrip-sensitive consumers). +## One-shot script (no notebook, no `.scadpkg` on disk) + +For a batch or unattended task whose deliverable is "one script, run as +`python model.py --output part.step`" (read-only source directory, no editor), +a plain script is the supported entry. A notebook is still the form for +anything a person will open, edit or reuse (core rule R3). The runtime keeps +no state next to the source when it runs without the cache. + +```python +import argparse +from pathlib import Path + +import simplecadapi as scad + + +@scad.part(id="plate", project_root=Path(__file__).parent) +def build_plate() -> scad.Part: + body = scad.make_box_rsolid(width=120.0, height=120.0, depth=10.0) + return scad.make_part_rpart("plate", body) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + args = parser.parse_args() + report = scad.exporter.export_product_package_to_step(build_plate(), args.output) + print(report.output_path, report.definition_ids) +``` + +A notebook run unattended in a read-only directory uses +`run_notebook(path, cache=False)`. + ## Validation gates - Read the returned export report and print its actual fields per format diff --git a/docs/skill/references/domains/part-modeling.md b/docs/skill/references/domains/part-modeling.md index b08a10fe..4126484b 100644 --- a/docs/skill/references/domains/part-modeling.md +++ b/docs/skill/references/domains/part-modeling.md @@ -2,17 +2,16 @@ Model one physical mechanical part with the core geometry API: primitives, profiles, 3D features, booleans, detail features, and transforms. A part is -usually one solid body; it may hold several named bodies (see "Multi-body -parts" below). +one solid body: a Part holds exactly one solid (see "One Part, one solid" +below). -**Read when:** the deliverable is one physical part (one body or several bodies kept apart in one product item), or an +**Read when:** the deliverable is one physical part (one solid body), 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 - one product item: normally a single solid body, or several bodies of one part - (a case and its lid modelled side by side, the pieces of a split cut). + one product item: a single solid body. - The request names brackets, plates, enclosures bodies, shafts, spacers, bosses, flanges, blades, or any monolithic part geometry. - An existing part source needs a geometry edit: new features, dimension @@ -40,8 +39,7 @@ existing part source needs a geometry edit (Role 4 of `workflows/single-part-mod ## Outputs - A positive-volume, closed `Solid`, or a `Part` via `make_part_rpart` when the - product boundary is needed (`body=solid`, or `bodies={"base": base, "lid": lid}` - for a multi-body part). + product boundary is needed (`body=solid`). - Semantic tags on mating/role surfaces and numeric facts in metadata. - A replayable `GraphSession` model JSON when the flow must be replayable or interchange-exported. @@ -101,10 +99,11 @@ consult their own documentation before the first call. `list[Solid]` ordered by centroid (x, then y, then z); it never drops a piece. Pass `on_split="error"` where exactly one Solid is required. A tool that consumes the body still raises. `cut_rsolid_list(base, tools)` always returns - a list (empty when nothing is left). The pieces become bodies of the part: - `make_part_rpart(part_id=..., body=pieces)` gives `body_1 .. body_n`, - `bodies={"bar": pieces}` gives `bar_1 .. bar_n`. A body is one closed volume: - OCCT and STEP have no valid "one solid with several lumps". + a list (empty when nothing is left). The pieces are notebook values: a Part holds one solid, so + `make_part_rpart(part_id=..., body=pieces)` with several pieces is an error; + make one Part per piece in an Assembly, or union the pieces if they are one + body. A solid is one closed volume: OCCT and STEP have no valid "one solid + with several lumps". - The pieces are a `scad.Pieces` (a list) and keep flowing: an operation that takes one shape (`translate_shape`, `shell_rsolid`, a further `cut_rsolid(pieces, tool)`, fillet, chamfer ...) runs on every piece and returns `Pieces` in the same order. @@ -113,36 +112,33 @@ consult their own documentation before the first call. `union_rsolid(pieces)` joins them. A plain list passed to `cut_rsolid` is still base-then-tools. -## Multi-body parts - -- `make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid})` - keeps several solids apart in one Part. The dict order is the body order; - body ids are identifiers, unique within the part. -- Read them with `part.bodies` (`Body.body_id`, `Body.solid`, `Body.name`), - `part.get_body("lid")`, `part.body_ids()`. `part.body` is the Solid of a - one-body part (id `"body"`) and raises `TypeError` on a multi-body part. -- Bodies have no placements, joints or connectors: anything that moves, or is - bought or made separately, is a component of an assembly - (`domains/assembly-and-product.md`). Connectors on a multi-body part are - refused for now. -- Tool and construction solids are not bodies: leave them as notebook values - and do not pass them to the Part. -- Overlapping bodies are allowed but warned (`BodyOverlapWarning` when the - definition is built); check with `inspect_body_overlaps_rlist(part)`. Union - bodies that are one piece of material. -- Mass per body and in total: `measure_part_mass_rmassreport(part)` (density - from the part's material, or `density=`). -- The preview draws one node per body (`node//body:`); STEP export - writes one product holding every body. +## One Part, one solid + +- `make_part_rpart(part_id=scad.notebook_id(), body=bracket)`. A Part is the unit + an assembly places, mates and gives connectors to, so it holds exactly one + solid. Several solids (`bodies=`, a list of several pieces) are refused with a + ValueError: make one Part per solid and put them in an Assembly (identity + placements when they only sit together), or union / cut them into one solid. +- `part.body` is the Solid; `part.bodies` is that body as a one-element tuple + (`Body.body_id` is `"body"`, `Body.solid`, `Body.name`). +- Solids that sit side by side while the model is built (a tool next to the body + it cuts, the tools of a pattern before the cut, the pieces of a split cut) are + ordinary notebook values, not a Part. A part notebook that ends with one open + solid needs no product cell; with several open solids and no cell making the + Part there is no product (`SeveralOpenBodiesError` names them): make the Part + from the solid that is the part. The editor draws the open solids side by side + ("N open solids") and its `use_as_product` makes the Part from the chosen one. +- Mass: `measure_part_mass_rmassreport(part)` (density from the part's material, + or `density=`). ## Colours and names for people -- A body's display colour, opacity and name are one recorded operation: - `part = scad.set_body_appearance_rpart(part, "lid", color=(0.9, 0.3, 0.2), opacity=0.6, name="Lid")` - (a one-body part: leave `body_id` out, it is `"body"`). Colours are RGB floats +- A part's display colour, opacity and name are one recorded operation: + `part = scad.set_body_appearance_rpart(part, color=(0.9, 0.3, 0.2), opacity=0.6, name="Lid")` + (the body id is `"body"`, the default). Colours are RGB floats in [0, 1], linear like `Material.color`. `None` keeps a value, `reset=True` - clears all three first. Read back with `part.get_body("lid").color` / `.opacity` / `.name`. -- The material colour is the default; a body colour overrides it for display + clears all three first. Read back with `part.bodies[0].color` / `.opacity` / `.name`. +- The material colour is the default; the body colour overrides it for display only (preview, package scene, STEP surface colour of that solid). Mass and density never read it. STEP writes the colour and the body name, not the opacity. - Name a face, an edge or a vertex with a tag; there is no second naming system: diff --git a/docs/skill/references/scadpkg-format.md b/docs/skill/references/scadpkg-format.md index 843dc8d5..d09cf5df 100644 --- a/docs/skill/references/scadpkg-format.md +++ b/docs/skill/references/scadpkg-format.md @@ -75,12 +75,14 @@ by its own sha256 through the map above works uniformly. Part definitions carry: `units` (`"mm"`), `generator` (`{ocp_version}`), `solid_cache` (body reference — the BREP payload for in-process rebuild), `topology_snapshot_ref` (the tag channel, below), -`connectors`, `material_ref`, `metadata`, `profile`, `revision`, and, -only when a body has a display appearance, `appearance` -(`{"bodies": [{"body_id", "color", "opacity", "name"?}]}`: colour as -linear RGB in [0, 1] overriding the material colour for display; `name` -only on a one-body part, a multi-body part names its bodies in -`solid_cache.bodies`). +`connectors`, `material_ref`, `metadata`, `profile` +(`simplecad-part-definition-2`), `revision`, and, only when the body has +a display appearance, `appearance` +(`{"bodies": [{"body_id", "color", "opacity", "name"?}]}`, one row at +most: colour as linear RGB in [0, 1] overriding the material colour for +display). A part definition holds exactly one solid: `solid_cache` has +`evaluator_profile` and `body_ref` only (there is no `solid_cache.bodies` +and no profile 3; that existed only in the withdrawn 2.2.8 release). Assembly definitions carry `definition_refs`, `instances`, `relations`, `public_connectors`, `solved_snapshot`, `grounded_instance_ids`, `feature_graph_ref`. Identity is 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 index f0ec8d1a..bb2f6441 100644 --- a/docs/skill/references/workflows/single-part-modeling/role-4-build.md +++ b/docs/skill/references/workflows/single-part-modeling/role-4-build.md @@ -34,8 +34,8 @@ For the current stage: 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 (a cut that splits the body returns its pieces as a list: - keep them as bodies of the part only when that is the design). Repeat construct-feature -> Boolean -> verify for each + single Solid (a cut that splits the body returns its pieces as a list; + a Part holds one solid, so one Part per piece in an Assembly when that is the design). 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. diff --git a/docs/updates/2.2.9.md b/docs/updates/2.2.9.md new file mode 100644 index 00000000..ba8cf453 --- /dev/null +++ b/docs/updates/2.2.9.md @@ -0,0 +1,50 @@ +# 2.2.9 Update Notes + +2.2.9 replaces 2.2.8, which has been withdrawn (yanked on PyPI, its GitHub release deleted). 2.2.8 let a `Part` hold several +bodies, and a Part like that could not take part in an assembly. In 2.2.9 a Part holds exactly one solid again, and the +editor keeps what it needed from the change: several solids can sit side by side in a notebook while a model is built. +Everything else in 2.2.8 is in 2.2.9; the 2.2.8 notes describe it. + +## Why a Part holds one solid + +The editor has to let several solids exist in one notebook with no assembly constraint between them: to pattern a tool and then +cut, there is a moment where more than one tool and the body exist together. In 2.2.8 that was solved inside the Part. But a +Part is the unit an assembly places, mates and gives connectors to, and a Part of several bodies could do none of that: + +* connectors on it were refused, so no mate or public connector could reach it; +* `explode_rassembly` raised `TypeError` on any assembly that held one; +* the older scene compiler read the single body of a Part in four places. + +The solids side by side are intermediate values of a notebook, not product items. So they are notebook values now, and the +Part is one solid. The full account is in `design-docs/part-holds-one-solid.md`; every commit of the change states its own +reason. + +## What changes for you + +* **`make_part_rpart(part_id, body, name=None)`** takes one Solid, a `Body` (a solid with a display name, colour and opacity), or + a list of exactly one piece of a split cut. Several solids raise `ValueError`, which says what to do: one Part per solid in + an Assembly (identity placements when they only sit together), or union / cut them into one solid first. +* **Removed** (they only existed in the withdrawn 2.2.8): the `bodies=` argument, `Part.multi_body`, `scad.part_shape`, + `inspect_body_overlaps_rlist`, `BodyOverlap`, `BodyOverlapWarning`, and the part definition profile + `simplecad-part-definition-3` with `solid_cache.bodies`. A part definition is profile 2 with one solid, as before 2.2.8. + A model JSON recorded with several solids in one Part is refused on replay, with the same guidance. +* **A part notebook with one open solid** (`kind = "part"`, no cell making the Part) still gets the implicit Part. **With several + open solids** there is no product: running it raises `SeveralOpenBodiesError`, which names the solids; make the Part from the + one that is the part (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`), or make a Part of each and an Assembly + of them. +* **The editor** draws such open solids side by side and its header says "N open solids"; `use_as_product` makes the Part + from the solid you choose. One-body Parts, `Pieces` (the result of a splitting cut), connectors, mates and `explode_rassembly` + behave as they did. + +## Fixed + +* On Windows, `sca cell` no longer kills the editor it talks to: the editor's process id is probed without signalling it + (`os.kill(pid, 0)` on Windows terminates the process). +* Building from source: GCC 14 (`operators_promote.cpp` includes ``), MinGW (`_USE_MATH_DEFINES`, and + `build_occt.py` picks Ninja when a MinGW `g++` is the compiler), and the macOS wheels are built on `macos-15` (GitHub no + longer serves `macos-14`; the wheels still target macOS 14.0). + +## For contributors + +* `AGENT.md` records how to rebuild the C++ module on Windows and how commits are organised: one decision per commit, with the + reasons from the discussion that led to it. diff --git a/docs/updates/2.2.9.zh-CN.md b/docs/updates/2.2.9.zh-CN.md new file mode 100644 index 00000000..a0617d58 --- /dev/null +++ b/docs/updates/2.2.9.zh-CN.md @@ -0,0 +1,44 @@ +# 2.2.9 更新说明 + +2.2.9 取代 2.2.8。2.2.8 已被撤回(PyPI 上已 yank,GitHub 上的发布也已删除)。2.2.8 让一个 `Part` 可以持有多个 body, +而这样的 Part 没法参与装配。2.2.9 让 Part 重新只持有一个实体,同时保留编辑器需要的能力:建模过程中,笔记本里可以并排放着 +几个实体。2.2.8 的其余内容都在 2.2.9 里,请看 2.2.8 的更新说明。 + +## 为什么 Part 只持有一个实体 + +编辑器必须允许一个笔记本里同时存在几个实体,而且它们之间不需要装配约束:比如先阵列刀具再切割,总有一刻刀具有好几把, +本体也在。2.2.8 把这件事放进了 Part 里解决。但是 Part 是装配里被摆放、被配合、被挂接口的单位,而持有多个 body 的 Part 这些都做不到: + +* 它不能带连接器,所以任何配合或公开连接器都够不到它; +* 装配里只要有一个这样的 Part,`explode_rassembly` 就报 `TypeError`; +* 旧的场景编译器有四处直接读 Part 的唯一 body。 + +并排的几个实体只是笔记本里的中间值,不是产品里的零件。所以现在它们是笔记本里的普通值,Part 就是一个实体。完整的说明在 +`design-docs/part-holds-one-solid.md`,这次改动的每个提交都写明了各自的原因。 + +## 对你有什么影响 + +* **`make_part_rpart(part_id, body, name=None)`** 接受一个实体、一个 `Body`(带显示名、颜色和不透明度的实体),或者切割分裂后 + 恰好只有一块的列表。给多个实体会报 `ValueError`,并说明该怎么做:每个实体做成一个 Part 放进装配(只是放在一起时用单位位姿), + 或者先把它们合并、切割成一个实体。 +* **已移除**(它们只在被撤回的 2.2.8 里存在过):`bodies=` 参数、`Part.multi_body`、`scad.part_shape`、 + `inspect_body_overlaps_rlist`、`BodyOverlap`、`BodyOverlapWarning`,以及零件定义的 `simplecad-part-definition-3` 版式和 + `solid_cache.bodies`。零件定义回到 2.2.8 之前的第 2 版式,只含一个实体。记录了“一个 Part 里有多个实体”的模型 JSON, + 重放时会被拒绝,并给出同样的提示。 +* **零件笔记本里只有一个开放实体**(`kind = "part"`,没有任何单元格做出 Part)时,仍然自动得到隐式 Part。**有多个开放实体** + 时没有产品:运行会报 `SeveralOpenBodiesError`,并列出这些实体;请用真正作为零件的那个来做 Part + (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`),或者给每个实体各做一个 Part,再放进装配。 +* **编辑器**会把这些开放实体并排画出来,标题栏显示“N 个开放实体”;`use_as_product` 会用你选的那个实体做出 Part。 + 单个实体的 Part、`Pieces`(切割分裂的结果)、连接器、配合和 `explode_rassembly` 的行为都和以前一样。 + +## 修复 + +* 在 Windows 上,`sca cell` 不会再杀掉它正在对话的编辑器:现在探测编辑器进程时不再发信号(Windows 上 + `os.kill(pid, 0)` 会直接终止进程)。 +* 从源码构建:GCC 14(`operators_promote.cpp` 补上 ``)、MinGW(定义 `_USE_MATH_DEFINES`,以及编译器是 MinGW 的 + `g++` 时 `build_occt.py` 选用 Ninja);macOS 的安装包改在 `macos-15` 上构建(GitHub 不再提供 `macos-14`,安装包仍然面向 + macOS 14.0)。 + +## 给贡献者 + +* `AGENT.md` 记录了在 Windows 上如何重新编译 C++ 模块,以及提交该怎么组织:一个决策一个提交,说明里写清楚促成这个决策的讨论和原因。 diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 9360f592..0b10ca16 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -294,9 +294,7 @@ export function App() { "ok", solids === 0 ? surfaceNodes.length ? t("{{count}} surfaces shown, no solid yet", { count: surfaceNodes.length }) : t("no solid yet") - : solids === 1 - ? t("model shown: 1 part") - : t("model shown: {{count}} parts", { count: solids }), + : t("model shown: {{summary}}", { summary: previewSummary(preview.nodes) }), ); } catch (error) { log.push("error", error instanceof Error ? error.message : t("unable to open the preview")); diff --git a/editor-web/src/components/FeatureTree.tsx b/editor-web/src/components/FeatureTree.tsx index 0e4457e0..4aef9b44 100644 --- a/editor-web/src/components/FeatureTree.tsx +++ b/editor-web/src/components/FeatureTree.tsx @@ -155,6 +155,16 @@ export function FeatureTree({ )} + {!state.product && state.open.length > 1 && ( +
+ {t("{{count}} solids are open, and a Part holds exactly one solid. Choose the part:", { count: state.open.length })} + {state.open.map((body) => ( + + ))} +
+ )} {error &&
{error}
} {state.features.length === 0 ? ( @@ -164,7 +174,7 @@ export function FeatureTree({ {state.features.map((feature) => { const spec = feature.gui ? catalog?.specs.find((item) => item.id === feature.gui?.spec) : undefined; const problem = errorText(feature); - const productBodies = state.product ? (state.product.bodies ?? [state.product.body]) : []; + const productBodies = state.product ? [state.product.body] : []; const isProduct = feature.makes.some((name) => productBodies.includes(name)); const canEdit = Boolean(feature.gui && spec); const Icon = feature.gui ? FEATURE_ICONS[feature.gui.spec] : CODE_FEATURE_ICON; diff --git a/editor-web/src/i18n/locales/zh-CN.json b/editor-web/src/i18n/locales/zh-CN.json index 820150bb..7cf741b5 100644 --- a/editor-web/src/i18n/locales/zh-CN.json +++ b/editor-web/src/i18n/locales/zh-CN.json @@ -1044,8 +1044,7 @@ "loop": "环", "lowercase letters, digits and underscores, starting with a letter": "只能用小写字母、数字和下划线,并以字母开头", "mid": "中点", - "model shown: 1 part": "已显示模型:1 个零件", - "model shown: {{count}} parts": "已显示模型:{{count}} 个零件", + "model shown: {{summary}}": "已显示模型:{{summary}}", "modeled": "建模", "modifier": "修改", "move": "平移", @@ -1205,6 +1204,9 @@ "unused": "未使用", "use picked face": "使用所选面", "use {{body}}": "使用 {{body}}", + "1 open solid": "1 个开放实体", + "{{count}} open solids": "{{count}} 个开放实体", + "{{count}} solids are open, and a Part holds exactly one solid. Choose the part:": "有 {{count}} 个开放实体,而一个零件只能有一个实体。请选择作为零件的那个:", "use_as_product": "设为产品", "used": "已使用", "used by": "被使用于", diff --git a/editor-web/src/lib/features.ts b/editor-web/src/lib/features.ts index e7ec8ed8..d2ba91d2 100644 --- a/editor-web/src/lib/features.ts +++ b/editor-web/src/lib/features.ts @@ -68,8 +68,9 @@ export type FeatureState = { bodies: { name: string; produces: string }[]; open: string[]; tip: string | null; - /** The body the notebook's Part is built from: by a product cell, or (`implicit`, no cell) by the runtime from the open body. */ - product: { cell: string | null; body: string; bodies?: string[]; implicit?: boolean } | null; + /** The body the notebook's Part is built from: by a product cell, or (`implicit`, no cell) by the runtime from the one open body. + * A Part holds exactly one solid: with several open solids and no product cell this is null and the author chooses. */ + product: { cell: string | null; body: string; implicit?: boolean } | null; defaults: Record>; /** The face-making sketches of the notebook (what a `profile` slot chooses from). */ profiles?: { name: string; plane: string | { origin: number[]; x_axis: number[]; y_axis: number[] }; normal: string; normal_vec?: [number, number, number] }[]; diff --git a/editor-web/src/lib/preview.test.ts b/editor-web/src/lib/preview.test.ts index 48b68c69..a08cc957 100644 --- a/editor-web/src/lib/preview.test.ts +++ b/editor-web/src/lib/preview.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; describe("the header's count of a preview", () => { - it("counts parts, and the bodies of a multi-body part", async () => { + it("counts parts, and the open solids of a notebook that has no Part yet (a Part holds one solid)", async () => { const { previewSummary } = await import("@/lib/preview"); expect(previewSummary([{ nodeId: "node/plate" }])).toBe("1 part"); - expect(previewSummary([{ nodeId: "node/body/body:base" }, { nodeId: "node/body/body:post" }])).toBe("1 part · 2 bodies"); + expect(previewSummary([{ nodeId: "node/body/body:base" }, { nodeId: "node/body/body:tool" }])).toBe("2 open solids"); + expect(previewSummary([{ nodeId: "node/body/body:base" }])).toBe("1 open solid"); expect(previewSummary([{ nodeId: "node/a" }, { nodeId: "node/b" }])).toBe("2 parts"); }); }); diff --git a/editor-web/src/lib/preview.ts b/editor-web/src/lib/preview.ts index c1b1c3a1..ae57f0b4 100644 --- a/editor-web/src/lib/preview.ts +++ b/editor-web/src/lib/preview.ts @@ -132,16 +132,19 @@ export function parsePreview(buffer: ArrayBuffer, cache: ReadonlyMap/body:`) and bodies. */ /** The node id of a surface value of the notebook (a face or shell that is not part of the product): `node/surface/` * (simplecadapi/scene/preview.py: drawn from both sides, picked whole with the sketches and curves). */ export const SURFACE_NODE_PREFIX = "node/surface/"; export const isSurfaceNode = (nodeId: string): boolean => nodeId.startsWith(SURFACE_NODE_PREFIX); +/** What the header says about a preview: its parts, and the open solids of the notebook drawn beside them (scene nodes + * `node//body:`: a Part holds one solid, so these are loose values, not parts). */ export function previewSummary(all: readonly { nodeId: string }[]): string { const nodes = all.filter((node) => !isSurfaceNode(node.nodeId)); - const parts = new Set(nodes.map((node) => node.nodeId.replace(/\/body:[^/]+$/, ""))); - const partCount = parts.size === 1 ? t("1 part") : t("{{count}} parts", { count: parts.size }); - const bodyCount = nodes.length === 1 ? t("1 body") : t("{{count}} bodies", { count: nodes.length }); - return nodes.length > parts.size ? `${partCount} · ${bodyCount}` : partCount; + const open = nodes.filter((node) => /\/body:[^/]+$/.test(node.nodeId)).length; + const placed = nodes.length - open; + const partCount = placed === 1 ? t("1 part") : t("{{count}} parts", { count: placed }); + if (open === 0) return partCount; + const openCount = open === 1 ? t("1 open solid") : t("{{count}} open solids", { count: open }); + return placed > 0 ? `${partCount} · ${openCount}` : openCount; } diff --git a/examples/SDK_DEFECTS.md b/examples/SDK_DEFECTS.md index 378824ba..84a2d401 100644 --- a/examples/SDK_DEFECTS.md +++ b/examples/SDK_DEFECTS.md @@ -421,9 +421,8 @@ that surfaced it is in brackets. - **Root cause:** a Compound is a new shape root with its own entity registry; the semantic state of the input roots is not carried over, and the copy helper only works within one root. -- **Status:** open. Multi-body parts keep their bodies as separate Solid roots - (`Part.bodies`), so they are not affected; `product.bodies.part_shape` - documents that its Compound is a geometry view without tags. +- **Status:** open. The multi-body Part that made this matter was withdrawn (a Part holds one Solid), so no + product value is affected any more; a user-built Compound still drops tags. - **Fix direction:** either carry tags into a Compound built from tagged roots (sub-shapes are shared, so the mapping is exact), or refuse `_copy_semantic_state_from` across roots instead of doing nothing. @@ -437,7 +436,8 @@ that surfaced it is in brackets. - **Root cause:** `_glue/values.py` parses the parameter names out of the pybind11 `__init__` docstring and reads each back with `getattr`. - **Status:** worked around: `_ARGUMENTS` maps a class to per-parameter - readers (Part: `body`/`bodies`). + readers (Part: `body`, which is the `Body` when the body carries a name or an appearance; the `bodies` + argument went with the multi-body Part). - **Fix direction:** give the C++ values an explicit `__getstate__`/ `py::pickle` (as `ql` selectors have) instead of docstring parsing. @@ -450,17 +450,19 @@ that surfaced it is in brackets. - **Trigger:** any part with more than one solid. - **Root cause:** "is a part" and "has one solid" were the same fact until now. -- **Status:** worked around: bodies get an id segment `body:` - (`entity//body:/...`, `node//body:`), one-body parts - keep their old ids byte for byte; the kind stays `single_solid`. +- **Status:** resolved by withdrawing the multi-body Part: a Part is one solid again, so `single_solid` is + accurate. The `body:` id segment (`entity//body:/...`, `node//body:`) + remains only for the open solids of a notebook that has no Part yet (a preview of loose solids); Part ids + are the old ones byte for byte. - **Fix direction:** treat `single_solid` as the name of the part kind (rename at the next package schema bump) and keep the `body:` segment the only way to address a body. ## 30. Error guidance is frozen by parity goldens [multi-solid research] -- **Symptom:** the `make_part_rpart` guidance still says "Union intended multiple bodies into one Solid before - creating the Part.", which is wrong now that `bodies=` exists; changing it fails +- **Symptom:** the `make_part_rpart` guidance says "Union intended multiple bodies into one Solid before + creating the Part." (it was wrong while `bodies=` existed, and is right again now that a Part holds one solid; it + does not mention the Assembly alternative). Changing it fails `src/cpp/tests/python/test_product_values.py::test_part_creation_and_errors`. - **Root cause:** the C++ port compares every error's `what_happened` / `possible_causes` / `how_to_fix` with goldens captured from the old Python SDK (`tests/golden/product_*.json`), so guidance cannot follow the API. diff --git a/pyproject.toml b/pyproject.toml index 0d330400..f8bdcd35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.8" +version = "2.2.9" description = "A simplified OpenCASCADE-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } @@ -79,7 +79,10 @@ Issues = "https://github.com/PhySpace/SimpleCADAPI/issues" [build-system] # One distribution: the Python package and the compiled C++ module (``simplecadapi._cpp``, src/cpp/) it runs on. requires = ["scikit-build-core>=0.10", "pybind11>=2.13", "ninja>=1.11; platform_system == 'Windows'"] -build-backend = "scikit_build_core.build" +# scad_build (tools/build_backend) is scikit-build-core plus toolchain/OpenCASCADE detection on Windows, so that a plain +# `uv sync` builds without hand-set CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS. +build-backend = "scad_build" +backend-path = ["tools/build_backend"] [tool.scikit-build] minimum-version = "build-system.requires" @@ -92,6 +95,7 @@ build-dir = "src/cpp/build/wheel/{wheel_tag}" wheel.packages = ["src/simplecadapi"] wheel.exclude = ["**/__pycache__/**", "**/*.pyc"] sdist.include = [ + "/tools/build_backend", "/src/simplecadapi/editor/webui", "/skillproj.toml", "/docs/skill", @@ -162,6 +166,18 @@ dev = [ "ty>=0.0.18", ] +[tool.uv] +# `uv run` / `uv sync` rebuild the editable install (the C++ module, minutes of compiling, and a C++ toolchain + OCCT on the +# machine) whenever a cache key changed. uv's default key is pyproject.toml, so a version bump or a dependency edit forced +# a rebuild nobody needed. Rebuild only when the C++ module's inputs change. +cache-keys = [ + { file = "src/cpp/CMakeLists.txt" }, + { file = "src/cpp/cmake/**" }, + { file = "src/cpp/include/**" }, + { file = "src/cpp/src/**" }, + { file = "src/cpp/bindings/**" }, +] + [tool.ty.environment] python-version = "3.10" diff --git a/src/cpp/bindings/python/bind_ops_features.cpp b/src/cpp/bindings/python/bind_ops_features.cpp index 383961f8..2a502ba3 100644 --- a/src/cpp/bindings/python/bind_ops_features.cpp +++ b/src/cpp/bindings/python/bind_ops_features.cpp @@ -683,9 +683,10 @@ void bind_booleans(py::module_& m) { m.def( "cut_rsolid", [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy, - const Choice& on_split) -> AnyOf { + const Choice& on_split, bool clean) -> AnyOf { const std::vector operands = flattened(solids, "cut_rsolid"); - const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + options.clean = clean; if (on_split.value == "error") return {py::cast(unlocked(false, [&] { return cut_rsolid(operands, options); }))}; if (on_split.value != "pieces") @@ -696,27 +697,29 @@ void bind_booleans(py::module_& m) { return {solids_to_py(outcome.pieces)}; // the cut split the body: every piece, ordered by centroid }, py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full", - py::arg("on_split") = "pieces", + py::arg("on_split") = "pieces", py::arg("clean") = true, "Compute the boolean difference of solids: the first is the base, the rest are subtracted in order."); m.def( "cut_rsolid_list", - [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy) { + [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy, bool clean) { const std::vector operands = flattened(solids, "cut_rsolid_list"); - const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + options.clean = clean; return solids_to_py(unlocked(false, [&] { return cut_rsolid_list(operands, options); })); }, py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full", + py::arg("clean") = true, "Compute the boolean difference of solids and keep every piece: a cut that splits the body returns each part, " "and one that consumes the body returns an empty list."); m.def( "intersect_rsolid", - [](const py::args& solids) { + [](const py::args& solids, bool clean) { const std::vector operands = flattened(solids, "intersect_rsolid"); - return unlocked(false, [&] { return intersect_rsolid(operands); }); + return unlocked(false, [&] { return intersect_rsolid(operands, clean); }); }, - "Compute the boolean intersection of solids."); + py::kw_only(), py::arg("clean") = true, "Compute the boolean intersection of solids."); m.def( "boolean_rsolidlist", diff --git a/src/cpp/bindings/python/bind_product.cpp b/src/cpp/bindings/python/bind_product.cpp index 8de0ab1f..4ec66d89 100644 --- a/src/cpp/bindings/python/bind_product.cpp +++ b/src/cpp/bindings/python/bind_product.cpp @@ -38,12 +38,9 @@ SCAD_SIGNATURE_NAME(DirectionOrNone, scad::pybind::DirectionTag::name + kOrNone) SCAD_SIGNATURE_NAME(Directions, const_name("dict[str, ") + scad::pybind::DirectionTag::name + const_name("] | None")); SCAD_SIGNATURE_NAME(Weights, const_name("dict[str, float | Expr] | None")); SCAD_SIGNATURE_NAME(Solid, type_name()); -// make_part_rpart: one Solid, or the pieces of a split cut (each a body), or several named bodies. +// make_part_rpart: one Solid (or the single piece of a split cut). SCAD_SIGNATURE_NAME(SolidOrPieces, type_name() + kOr + const_name("collections.abc.Sequence[") + - type_name() + const_name("] | None")); -SCAD_SIGNATURE_NAME(Bodies, const_name("dict[str, ") + type_name() + kOr + - const_name("collections.abc.Sequence[") + type_name() + - const_name("]] | None")); + type_name() + const_name("]")); SCAD_SIGNATURE_NAME(Face, type_name()); SCAD_SIGNATURE_NAME(Edge, type_name()); SCAD_SIGNATURE_NAME(Vertex, type_name()); @@ -76,7 +73,6 @@ using DirectionsArg = AnyOf; using WeightsArg = AnyOf; using SolidArg = AnyOf; using SolidOrPiecesArg = AnyOf; -using BodiesArg = AnyOf; using FaceArg = AnyOf; using EdgeArg = AnyOf; using VertexArg = AnyOf; @@ -153,27 +149,14 @@ void bind_structure(py::module_& m) { m.def( "make_part_rpart", - [](const TextArg& part_id, const SolidOrPiecesArg& body, const NameArg& name, const BodiesArg& bodies) { + [](const TextArg& part_id, const SolidOrPiecesArg& body, const NameArg& name) { return guard_operation("make_part_rpart", [&] { - if (body.is_none() == bodies.is_none()) - throw scad::TypeError("give exactly one of body (a Solid) or bodies (several named solids)"); - if (!bodies.is_none()) - return sp::make_part_rpart(text(part_id, "part_id"), body_list(bodies), optional_name(name)); - if (is_pieces(body) && py::len(body) > 1) { // a split cut: its pieces are bodies body_1 .. body_n - std::vector pieces; - add_bodies(pieces, sp::kDefaultBodyId, body); - return sp::make_part_rpart(text(part_id, "part_id"), pieces, optional_name(name)); - } - if (is_pieces(body)) - return sp::make_part_rpart( - text(part_id, "part_id"), - typed(py::reinterpret_borrow(body.value[py::int_(0)]), "body"), optional_name(name)); - return sp::make_part_rpart(text(part_id, "part_id"), typed(body, "body must be a Solid"), - optional_name(name)); + const std::string id = text(part_id, "part_id"); + return sp::make_part_rpart(id, single_solid(body, id), optional_name(name)); }); }, - py::arg("part_id"), py::arg("body") = py::none(), py::arg("name") = py::none(), py::arg("bodies") = py::none(), - "Wrap one Solid (body) or several named solids (bodies) as a semantic Part."); + py::arg("part_id"), py::arg("body"), py::arg("name") = py::none(), + "Wrap one Solid (body) as a semantic Part. A Part holds exactly one solid."); m.def( "assign_material_rpart", [](const PartArg& part, const MaterialArg& material) { diff --git a/src/cpp/bindings/python/bind_product_args.hpp b/src/cpp/bindings/python/bind_product_args.hpp index cbe8ddf4..eda787d0 100644 --- a/src/cpp/bindings/python/bind_product_args.hpp +++ b/src/cpp/bindings/python/bind_product_args.hpp @@ -58,39 +58,18 @@ inline bool is_pieces(const py::object& value) { py::isinstance(py::reinterpret_borrow(value[py::int_(0)])); } -// One mapping entry: a Solid is one body; the pieces of a split cut are bodies _1 .. _n (one piece keeps ). -inline void add_bodies(std::vector& out, const std::string& body_id, const py::object& value) { - if (!is_pieces(value)) { - out.emplace_back(body_id, typed(value, "bodies values must be Solids or lists of Solids")); - return; - } +// The one Solid of a Part: a Solid, or the pieces of a split cut when there is only one piece. Several pieces are +// several solids, which a Part cannot hold: ValueError says what to do instead. +inline Solid single_solid(const py::object& value, const std::string& part_id) { + if (!is_pieces(value)) + return typed(value, "body must be a Solid"); const py::sequence pieces = py::reinterpret_borrow(value); - const std::size_t count = pieces.size(); - for (std::size_t index = 0; index < count; ++index) { - out.emplace_back(count == 1 ? body_id : body_id + "_" + std::to_string(index + 1), - typed(py::reinterpret_borrow(pieces[index]), - "bodies values must be Solids or lists of Solids")); + if (pieces.size() != 1) { + throw scad::ValueError("part '" + part_id + "' was given " + std::to_string(pieces.size()) + + " solids (the pieces of a split cut); a Part holds exactly one solid. Make one Part per " + "piece and put them in an Assembly, or union the pieces if they are one body"); } -} - -// The bodies of a multi-body Part: a mapping body_id -> Solid or pieces (in its order), or a sequence of Body values. -inline std::vector body_list(const py::object& value) { - std::vector out; - if (py::isinstance(value) || py::hasattr(value, "items")) { - for (const py::handle item : value.attr("items")()) { - const py::tuple pair = py::reinterpret_borrow(item); - add_bodies(out, text(py::reinterpret_borrow(pair[0]), "body_id"), - py::reinterpret_borrow(pair[1])); - } - return out; - } - if (py::isinstance(value) || !py::isinstance(value)) - throw scad::TypeError("bodies must be a mapping body_id -> Solid or a sequence of Body values"); - for (const py::handle item : value) { - out.push_back(typed(py::reinterpret_borrow(item), - "bodies must be a mapping body_id -> Solid or a sequence of Body values")); - } - return out; + return typed(py::reinterpret_borrow(pieces[0]), "body must be a Solid"); } inline scad::Scalar scalar(const py::object& value, const char* field) { diff --git a/src/cpp/bindings/python/bind_product_values.cpp b/src/cpp/bindings/python/bind_product_values.cpp index cd8d2f3f..eb29db0d 100644 --- a/src/cpp/bindings/python/bind_product_values.cpp +++ b/src/cpp/bindings/python/bind_product_values.cpp @@ -424,7 +424,7 @@ void bind_constraints(py::module_& m) { void bind_part(py::module_& m) { using namespace sp; - py::class_(m, "Body", "One named solid of a Part; a multi-body Part keeps several apart.") + py::class_(m, "Body", "The solid of a Part, with its display name, colour and opacity.") .def(py::init([](const py::object& body_id, const py::object& solid, const py::object& name, const py::object& color, const py::object& opacity) { std::optional opacity_value; @@ -454,10 +454,9 @@ void bind_part(py::module_& m) { return out + ")"; }); - py::class_ cls( - m, "Part", "Product item holding one Solid (`body`) or several named bodies (`bodies`), never placed apart."); + py::class_ cls(m, "Part", "Product item holding exactly one Solid (`body`)."); cls.def(py::init([](const py::object& part_id, const py::object& body, const py::object& name, - const py::object& material, const py::object& connectors, const py::object& bodies) { + const py::object& material, const py::object& connectors) { std::optional material_value; if (!material.is_none()) material_value = typed(material, "material must be a Material"); @@ -468,30 +467,25 @@ void bind_part(py::module_& m) { "connectors must contain Connector values")); } } - if (body.is_none() == bodies.is_none()) - throw scad::TypeError("give exactly one of body (a Solid) or bodies (several named solids)"); - if (!bodies.is_none() || is_pieces(body)) { // several bodies, or the pieces of a split cut - std::vector values; - if (bodies.is_none()) - add_bodies(values, kDefaultBodyId, body); - else - values = body_list(bodies); - return std::make_shared(text(part_id, "part_id"), std::move(values), optional_name(name), + if (py::isinstance(body)) { // a Body keeps its id, display name and appearance (pickle, copy) + std::vector only{typed(body, "body must be a Solid or a Body")}; + return std::make_shared(text(part_id, "part_id"), std::move(only), optional_name(name), material_value, std::move(connector_values)); } - return std::make_shared(text(part_id, "part_id"), typed(body, "body must be a Solid"), + return std::make_shared(text(part_id, "part_id"), single_solid(body, text(part_id, "part_id")), optional_name(name), material_value, std::move(connector_values)); }), - py::arg("part_id"), py::arg("body") = py::none(), py::arg("name") = py::none(), - py::arg("material") = py::none(), py::arg("connectors") = py::tuple(), py::arg("bodies") = py::none()) - .def_property_readonly("bodies", [](const Part& self) { return to_tuple(self.bodies()); }) - .def_property_readonly("multi_body", &Part::multi_body) + py::arg("part_id"), py::arg("body"), py::arg("name") = py::none(), py::arg("material") = py::none(), + py::arg("connectors") = py::tuple()) + .def_property_readonly( + "bodies", [](const Part& self) { return to_tuple(self.bodies()); }, + "The Part's body as a one-element tuple (the Body carries the display appearance).") .def("body_ids", [](const Part& self) { return to_tuple(self.body_ids()); }) .def( "get_body", [](const Part& self, const py::object& id) { return self.get_body(text(id, "body_id")); }, py::arg("body_id")) .def_property_readonly("part_id", &Part::part_id) - .def_property_readonly("body", &Part::body, "The Solid of a one-body Part; TypeError for a multi-body Part.") + .def_property_readonly("body", &Part::body, "The Solid of the Part.") .def_property_readonly("name", [](const Part& self) { return optional_object(self.name()); }) .def_property_readonly( "material", diff --git a/src/cpp/include/scad/kernel/booleans.hpp b/src/cpp/include/scad/kernel/booleans.hpp index 38b8f620..a5f96f02 100644 --- a/src/cpp/include/scad/kernel/booleans.hpp +++ b/src/cpp/include/scad/kernel/booleans.hpp @@ -22,6 +22,10 @@ struct BooleanOptions { bool fill_history = false; // keep BRepTools_History + section edges std::optional fuzzy; // fuzzy tolerance (SetFuzzyValue) bool glue = false; // BOPAlgo_GlueShift instead of GlueOff + // Unify the same-domain faces and edges of the result (what a cut leaves behind: a cylinder wall split along its + // seam, an edge split in two). The history, when kept, and the section edges follow the clean-up. A clean-up that + // fails, or that turns a valid result into an invalid one, is dropped: the raw result is the answer. + bool clean = false; }; struct BooleanResult { @@ -55,10 +59,10 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector& shap TopoDS_Shape fuse_shapes(const std::vector& shapes, bool glue = false, std::optional tol = std::nullopt, bool clean = true); -// body - every tool. `body` itself when there are no tools. -TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools); +// body - every tool. `body` itself when there are no tools. With `clean`, the result is unified (see BooleanOptions). +TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools, bool clean = false); -// Intersection of all shapes, folded left to right. -TopoDS_Shape common_shapes(const std::vector& shapes); +// Intersection of all shapes, folded left to right. With `clean`, each step's result is unified. +TopoDS_Shape common_shapes(const std::vector& shapes, bool clean = false); } // namespace scad::kernel diff --git a/src/cpp/include/scad/ops/features/booleans.hpp b/src/cpp/include/scad/ops/features/booleans.hpp index 86e74811..c9c3d4a4 100644 --- a/src/cpp/include/scad/ops/features/booleans.hpp +++ b/src/cpp/include/scad/ops/features/booleans.hpp @@ -27,6 +27,9 @@ struct CutOptions { // cut_rsolid_list: order the pieces by centroid (x, then y, then z) instead of kernel order, so piece i is the same // piece on every OCCT version. Recorded as params.piece_order = "centroid" only when set. bool order_pieces = false; + // Unify the faces and edges the cut leaves split (a cylinder wall cut along its seam), as union does: OCCT fails to + // fillet such a split face, or returns an invalid solid. Recorded as params.clean. + bool clean = true; }; // What a cut that may split leaves: one Solid, or (`solid` empty) every piece, ordered by centroid. @@ -47,8 +50,8 @@ std::vector cut_rsolid_list(const std::vector& solids, const CutOp // cut_rsolid_list with order_pieces (recorded as make_cut_rsolid_list). An empty result is still an error. CutOutcome cut_rsolid_keep_split(const std::vector& solids, const CutOptions& options = {}); -// The overlap of all `solids`. Error "intersect_rsolid". -Solid intersect_rsolid(const std::vector& solids); +// The overlap of all `solids`; `clean` as for CutOptions. Error "intersect_rsolid". +Solid intersect_rsolid(const std::vector& solids, bool clean = true); // The error a non-Solid argument of union_rsolid / cut_rsolid / intersect_rsolid raises // (a binding that flattens Python arguments throws it for an item that is not a solid). diff --git a/src/cpp/include/scad/ops/features/diagnostics.hpp b/src/cpp/include/scad/ops/features/diagnostics.hpp index 2d966b7d..74568e17 100644 --- a/src/cpp/include/scad/ops/features/diagnostics.hpp +++ b/src/cpp/include/scad/ops/features/diagnostics.hpp @@ -21,7 +21,8 @@ namespace scad { // A classified fillet / chamfer failure. struct BlendDiagnosis { // "tangent_adjacent_faces" | "sliver_edges_in_selection" | "size_exceeds_face" | - // "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict" + // "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict" | + // "faces_split_on_one_surface" std::string failure_kind; std::string what_happened; std::vector possible_causes; @@ -54,6 +55,16 @@ class BlendVolumeViolation : public ValueError { void assert_blend_volume_monotonic(const Solid& source, const Solid& result, std::string_view operation_kind, std::optional size_value, const std::vector& edges); +// Exit check of a blend, next to the volume check: the result must be a valid solid (BRepCheck_Analyzer) whenever +// the input was. OCCT reports `IsDone` for fillets it got wrong (a face split along a seam, issue #88) and hands back +// an invalid solid with a wrong volume, which only a later boolean or an export finds. Throws BlendValidityViolation. +class BlendValidityViolation : public BlendVolumeViolation { + public: + using BlendVolumeViolation::BlendVolumeViolation; +}; +void assert_blend_valid(const Solid& source, const Solid& result, std::string_view operation_kind, + std::optional size_value, const std::vector& edges); + // A classified boolean failure. struct BooleanDiagnosis { std::string failure_kind; // "disjoint" | "non_manifold_contact" diff --git a/src/cpp/include/scad/product/operators.hpp b/src/cpp/include/scad/product/operators.hpp index e56d8c81..b8344f9e 100644 --- a/src/cpp/include/scad/product/operators.hpp +++ b/src/cpp/include/scad/product/operators.hpp @@ -39,8 +39,6 @@ Placement make_placement_rplacement(const Scalar3& origin, const Scalar3& x_axis Placement identity_placement_rplacement(); PartPtr make_part_rpart(const std::string& part_id, const Solid& body, const OptionalString& name = {}); -// A Part of several named bodies, kept apart (no boolean between them), in this order. -PartPtr make_part_rpart(const std::string& part_id, const std::vector& bodies, const OptionalString& name = {}); PartPtr assign_material_rpart(const Part& part, const Material& material); // The Part with the display colour, opacity and display name of one body set. An argument left empty keeps what // the body has; `reset` first clears all three (the body then shows the material colour, opaque, under its id). diff --git a/src/cpp/include/scad/product/part.hpp b/src/cpp/include/scad/product/part.hpp index b3b5faa6..e7c57377 100644 --- a/src/cpp/include/scad/product/part.hpp +++ b/src/cpp/include/scad/product/part.hpp @@ -1,9 +1,10 @@ -// A Part: one or more named bodies (Solids), with an optional material and its connector datums -// (product/part.py). Values are immutable: every `with_*` returns a new Part. +// A Part: one Solid (its body), with an optional material and its connector datums (product/part.py). Values are +// immutable: every `with_*` returns a new Part. // -// A one-body Part is what every Part was before multi-body parts: `body()` is its Solid. A multi-body -// Part keeps several solids apart inside one product item (bodies of one part are not components: they -// have no placements, joints or constraints between them). See design-docs/multi-solid-design.md. +// A Part holds exactly one solid: it is the unit an assembly places, mates and gives connectors to. Several solids +// that exist side by side while a model is built (a tool and its target) are loose values of the notebook, and a +// deliverable of several solids is an Assembly of Parts. The body is kept as a `Body` only because it carries the +// display name, colour and opacity (`set_body_appearance_rpart`). #pragma once #include @@ -61,16 +62,15 @@ class Part : public SemanticValue { // ValueError for a malformed id, an empty name, or duplicate connector ids. Part(std::string part_id, Solid body, OptionalString name = {}, std::optional material = {}, std::vector connectors = {}); - // A Part of several bodies, in this order. ValueError for no bodies, duplicate body ids, or connectors on - // more than one body (connectors of a multi-body part are not supported yet). + // A Part of the one body in `bodies` (a vector so that the body keeps its id and appearance). ValueError for any + // other number of bodies. Part(std::string part_id, std::vector bodies, OptionalString name = {}, std::optional material = {}, std::vector connectors = {}); const std::string& part_id() const noexcept { return part_id_; } - // The Solid of a one-body Part. TypeError for a multi-body Part: read bodies() instead. const Solid& body() const; + // Always one element. const std::vector& bodies() const noexcept { return bodies_; } - bool multi_body() const noexcept { return bodies_.size() > 1; } std::vector body_ids() const; // KeyError ("part has no body_id 'x'") for an unknown id. const Body& get_body(std::string_view body_id) const; diff --git a/src/cpp/include/scad/semantic/tracking.hpp b/src/cpp/include/scad/semantic/tracking.hpp index dfde8f60..77ab47df 100644 --- a/src/cpp/include/scad/semantic/tracking.hpp +++ b/src/cpp/include/scad/semantic/tracking.hpp @@ -179,8 +179,9 @@ ModificationHistory modification_history(const BRepTools_History& history, const // tracked_cut. Face history of body ("body") and tool ("tool"), plus the // section edges. `solid` of the result is the *first* solid of the kernel's // result, empty when the cut left none; membership is judged against the whole -// kernel result. ValueError when the algorithm did not complete. -TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool); +// kernel result. ValueError when the algorithm did not complete. With `clean` the result is unified (same-domain +// faces and edges merged, see kernel::BooleanOptions::clean) and the history follows the clean-up. +TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool, bool clean = false); // tracked_union: a two-solid fuse with `track_union_history` on top. ValueError // unless the fuse yields exactly one solid. Glue is opt-in (see kernel::fuse_shapes_with_history). @@ -195,8 +196,8 @@ TrackedBooleanResult track_union_history(const std::vector& solids, const const BRepTools_History& history, const std::vector& section_edges = {}); -// tracked_intersect. -TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool); +// tracked_intersect. `clean` as for tracked_cut. +TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool, bool clean = false); namespace tracking { // How many topology-history queries the tracked operations have made in this process. A graph-policy diff --git a/src/cpp/src/kernel/booleans.cpp b/src/cpp/src/kernel/booleans.cpp index 46aff8eb..e62b6359 100644 --- a/src/cpp/src/kernel/booleans.cpp +++ b/src/cpp/src/kernel/booleans.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,62 @@ std::vector tail_of(const std::vector& shapes) { return std::vector(shapes.begin() + 1, shapes.end()); } +bool is_valid_shape(const TopoDS_Shape& shape) { + return !shape.IsNull() && BRepCheck_Analyzer(shape).IsValid() == Standard_True; +} + +// Follow each section edge through a clean-up: it may have been merged into a longer edge or dropped with a removed +// face. Only the edges that are in `cleaned` survive, each once. +std::vector follow_section_edges(const opencascade::handle& clean_history, + const TopoDS_Shape& cleaned, + const std::vector& section_edges) { + SubShapeIndex result(cleaned); + TopTools_IndexedMapOfShape seen; + std::vector survivors; + for (const TopoDS_Shape& edge : section_edges) { + std::vector candidates; + for (const TopoDS_Shape& s : clean_history->Modified(edge)) + candidates.push_back(s); + for (const TopoDS_Shape& s : clean_history->Generated(edge)) + candidates.push_back(s); + if (candidates.empty() && !clean_history->IsRemoved(edge)) + candidates.push_back(edge); + for (const TopoDS_Shape& candidate : candidates) { + if (!result.contains(candidate)) + continue; + const int before = seen.Extent(); + seen.Add(candidate); + if (seen.Extent() > before) + survivors.push_back(candidate); + } + } + return survivors; +} + +// BooleanOptions::clean. The unified shape replaces the result only when it is as good: the same number of solids, and +// valid whenever the raw result was (FreeCAD and build123d fall back to the raw result the same way; unifying every +// boolean result has been seen to invalidate some). +void unify_result(BooleanResult& result) { + try { + ShapeUpgrade_UnifySameDomain unifier(result.shape, /*UnifyEdges=*/true, /*UnifyFaces=*/true, + /*ConcatBSplines=*/true); + unifier.Build(); + const TopoDS_Shape cleaned = unifier.Shape(); + if (cleaned.IsNull() || solids_of(cleaned).size() != solids_of(result.shape).size()) + return; + if (!is_valid_shape(cleaned) && is_valid_shape(result.shape)) + return; + if (!result.history.IsNull()) { + const opencascade::handle clean_history = unifier.History(); + result.history->Merge(clean_history); + result.section_edges = follow_section_edges(clean_history, cleaned, result.section_edges); + } + result.shape = cleaned; + } catch (const Standard_Failure&) { + // the raw result stands + } +} + } // namespace BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std::vector& tools, @@ -86,6 +143,8 @@ BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std: out.section_edges.clear(); } } + if (options.clean) + unify_result(out); return out; }); } @@ -138,29 +197,7 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector& shap const opencascade::handle clean_history = unifier.History(); out.history->Merge(clean_history); - // Follow each section edge through the clean-up: it may have been merged - // into a longer edge or dropped with a removed face. - SubShapeIndex result(out.shape); - TopTools_IndexedMapOfShape seen; - std::vector survivors; - for (const TopoDS_Shape& edge : out.section_edges) { - std::vector candidates; - for (const TopoDS_Shape& s : clean_history->Modified(edge)) - candidates.push_back(s); - for (const TopoDS_Shape& s : clean_history->Generated(edge)) - candidates.push_back(s); - if (candidates.empty() && !clean_history->IsRemoved(edge)) - candidates.push_back(edge); - for (const TopoDS_Shape& candidate : candidates) { - if (!result.contains(candidate)) - continue; - const int before = seen.Extent(); - seen.Add(candidate); - if (seen.Extent() > before) - survivors.push_back(candidate); - } - } - out.section_edges = std::move(survivors); + out.section_edges = follow_section_edges(clean_history, out.shape, out.section_edges); return out; }); } @@ -182,21 +219,25 @@ TopoDS_Shape fuse_shapes(const std::vector& shapes, bool glue, std return clean ? clean_shape(fused.shape) : fused.shape; } -TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools) { +TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools, bool clean) { if (tools.empty()) return body; - const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools); + BooleanOptions options; + options.clean = clean; + const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools, options); if (!cut.done) throw ValueError("OCP cut failed"); return cut.shape; } -TopoDS_Shape common_shapes(const std::vector& shapes) { +TopoDS_Shape common_shapes(const std::vector& shapes, bool clean) { if (shapes.empty()) throw ValueError("common_shapes requires at least one shape"); TopoDS_Shape result = shapes.front(); for (std::size_t i = 1; i < shapes.size(); ++i) { - const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]}); + BooleanOptions options; + options.clean = clean; + const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]}, options); if (!common.done) throw ValueError("OCP common failed"); result = common.shape; diff --git a/src/cpp/src/ops/features/blend_diagnosis.cpp b/src/cpp/src/ops/features/blend_diagnosis.cpp index 6db55152..85355abb 100644 --- a/src/cpp/src/ops/features/blend_diagnosis.cpp +++ b/src/cpp/src/ops/features/blend_diagnosis.cpp @@ -1,5 +1,13 @@ // Why a fillet or chamfer failed: the probes of operators/_diagnostics.py, in its order. #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include @@ -153,6 +161,80 @@ std::optional classify_tangent_adjacency(const Case& c) { return std::nullopt; } +// A boolean that was not cleaned leaves faces that lie on one surface split (a cylinder wall cut along its seam is two +// faces). The blend of an edge along such a face fails, or goes wrong, and the room probe below then blames the +// radius: the "face room" it measures is only the half the seam cut off. Unify the same-domain faces, carry the +// selected edges over with the history, and blend again: when that works, the split faces are the cause (issue #88). +std::optional classify_split_faces(const Case& c) { + ShapeUpgrade_UnifySameDomain unifier(c.solid.wrapped(), /*UnifyEdges=*/true, /*UnifyFaces=*/true, + /*ConcatBSplines=*/true); + unifier.Build(); + const TopoDS_Shape unified = unifier.Shape(); + if (unified.IsNull()) + return std::nullopt; + const auto faces_in = [](const TopoDS_Shape& shape) { + TopTools_IndexedMapOfShape faces; + TopExp::MapShapes(shape, TopAbs_FACE, faces); + return faces.Extent(); + }; + const int split = faces_in(c.solid.wrapped()) - faces_in(unified); + if (split <= 0) + return std::nullopt; + + const opencascade::handle history = unifier.History(); + TopTools_MapOfShape carried; + std::vector edges; + for (const Edge& edge : c.edges) { + std::vector now; + for (const TopoDS_Shape& s : history->Modified(edge.wrapped())) + now.push_back(s); + if (now.empty() && !history->IsRemoved(edge.wrapped())) + now.push_back(edge.wrapped()); + for (const TopoDS_Shape& s : now) { + if (s.ShapeType() == TopAbs_EDGE && carried.Add(s)) + edges.push_back(TopoDS::Edge(s)); + } + } + if (edges.empty()) + return std::nullopt; + + const bool chamfer = c.words.size == "distance"; + const char* kind = chamfer ? "chamfer" : "fillet"; + const bool works = occt_guard([&] { + if (chamfer) { + BRepFilletAPI_MakeChamfer blend(unified); + for (const TopoDS_Edge& edge : edges) + blend.Add(c.size_value, edge); + blend.Build(); + return blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + } + BRepFilletAPI_MakeFillet blend(unified); + for (const TopoDS_Edge& edge : edges) + blend.Add(c.size_value, edge); + blend.Build(); + return blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + }); + if (!works) + return std::nullopt; + + BlendDiagnosis out; + out.failure_kind = "faces_split_on_one_surface"; + out.what_happened = std::to_string(split) + + " face(s) of the solid are split on a surface they share with a neighbour " + + "(typically a boolean made without clean, or a cylinder wall cut along its seam); the same " + + kind + " " + c.words.size + " " + g4(c.size_value) + + " mm succeeds once those faces are unified, so the " + c.words.size + " is not the limit"; + out.possible_causes = {"A cut or intersection ran with clean=False and left a face split along its seam.", + "The solid came from a source that does not unify faces (an imported or hand-built shape)."}; + out.measurements = {{"split_faces", static_cast(split), "faces"}, {c.words.size, c.size_value, "mm"}}; + out.repair = { + "Make the cut / intersect with clean=True (the default), or pass the solid through union_rsolid(solid, " + "clean=True) before the " + + std::string(kind) + ".", + std::string("Keep the ") + c.words.size + " as it is: it is not too large."}; + return out; +} + std::optional classify_blend_room(const Case& c) { for (const Edge& edge : c.edges) { const auto* faces = c.faces_of(edge); @@ -336,6 +418,8 @@ std::optional diagnose_blend_failure(const Solid& solid, const s return found; if (auto found = classify_tangent_adjacency(c)) return found; + if (auto found = classify_split_faces(c)) + return found; if (auto found = classify_blend_room(c)) return found; if (auto found = classify_partial_smooth_chain(c)) @@ -426,4 +510,20 @@ void assert_blend_volume_monotonic(const Solid& source, const Solid& result, std } } +void assert_blend_valid(const Solid& source, const Solid& result, std::string_view operation_kind, + std::optional size_value, const std::vector& edges) { + // The cheap side first: a valid result is the normal case; the input is checked only to tell "OCCT broke it" from + // "it came in broken" (an imported solid that was already invalid is not the blend's fault). + const bool result_valid = occt_guard([&] { return BRepCheck_Analyzer(result.wrapped()).IsValid() == Standard_True; }); + if (result_valid) + return; + const bool source_valid = occt_guard([&] { return BRepCheck_Analyzer(source.wrapped()).IsValid() == Standard_True; }); + if (!source_valid) + return; + throw BlendValidityViolation(std::string(operation_kind) + + " result is not a valid solid (BRepCheck_Analyzer) although the input was: the kernel " + "returned broken geometry (size=" + + size_text(size_value) + ", selected_edges=" + std::to_string(edges.size()) + ")"); +} + } // namespace scad diff --git a/src/cpp/src/ops/features/blends.cpp b/src/cpp/src/ops/features/blends.cpp index ed3b4877..276bae4c 100644 --- a/src/cpp/src/ops/features/blends.cpp +++ b/src/cpp/src/ops/features/blends.cpp @@ -52,7 +52,11 @@ struct BlendAttempt { spec.possible_causes = diagnosis->possible_causes; const std::string details = - violation != nullptr ? "BlendVolumeViolation: " + std::string(violation->what()) : technical_details_of(error); + violation != nullptr + ? std::string(dynamic_cast(&error) != nullptr ? "BlendValidityViolation: " + : "BlendVolumeViolation: ") + + violation->what() + : technical_details_of(error); std::vector evidence; if (diagnosis && !diagnosis->evidence_edges.empty()) { // The solid with the implicated edges in orange; the same failure at the same size is drawn once per process @@ -85,6 +89,7 @@ Solid finish_blend(const Solid& solid, TrackedResult tracked, const BlendAttempt double check_size, FeatureRecord& rec, const BlendTags& tags, std::string_view patch_role, const std::function& between = {}) { assert_blend_volume_monotonic(solid, Solid(tracked.shape), kind, check_size, attempt.edges); + assert_blend_valid(solid, Solid(tracked.shape), kind, check_size, attempt.edges); inherit_metadata(tracked.shape, {&solid}); rec.param("edge_count", Value(static_cast(attempt.edges.size()))); diff --git a/src/cpp/src/ops/features/booleans.cpp b/src/cpp/src/ops/features/booleans.cpp index 2359a761..b04bd7a5 100644 --- a/src/cpp/src/ops/features/booleans.cpp +++ b/src/cpp/src/ops/features/booleans.cpp @@ -323,12 +323,12 @@ CutRun run_cuts(const std::vector& solids, const CutOptions& options, Tra std::vector cut_pieces; if (policy == TrackingPolicy::Full) { - TrackedBooleanResult tracked = tracked_cut(piece, candidate); + TrackedBooleanResult tracked = tracked_cut(piece, candidate, options.clean); cut_pieces = std::move(tracked.solids); run.deltas.push_back(std::move(tracked.delta)); merge_entries(run.entries, std::move(tracked.delta_entries)); } else { - for (const TopoDS_Solid& part : kernel::solids_of(kernel::cut_shapes(s1, {s2}))) + for (const TopoDS_Solid& part : kernel::solids_of(kernel::cut_shapes(s1, {s2}, options.clean))) cut_pieces.emplace_back(part); } run.intermediates.push_back(piece); @@ -350,6 +350,7 @@ void record_cut(const std::vector& solids, const CutOptions& options, Tra FeatureRecord& rec) { rec.param("tool_count", Value(static_cast(solids.size() - 1))) .param("skip_non_intersecting", Value(options.skip_non_intersecting)) + .param("clean", Value(options.clean)) .param("tracking_policy", Value(std::string(to_string(policy)))); add_operands(rec, solids); } @@ -485,7 +486,7 @@ std::vector cut_rsolid_list(const std::vector& solids, const CutOp // --------------------------------------------------------------------------- // intersect // --------------------------------------------------------------------------- -Solid intersect_rsolid(const std::vector& solids) { +Solid intersect_rsolid(const std::vector& solids, bool clean) { bool empty_result = false; try { return occt_guard([&]() -> Solid { @@ -503,7 +504,7 @@ Solid intersect_rsolid(const std::vector& solids) { const Solid& candidate = solids[i]; if (result_solid.wrapped().IsNull() || candidate.wrapped().IsNull()) throw ValueError("The input solid is invalid; the intersection cannot be computed."); - TrackedBooleanResult tracked = tracked_intersect(result_solid, candidate); + TrackedBooleanResult tracked = tracked_intersect(result_solid, candidate, clean); if (!tracked.solid) { empty_result = true; throw ValueError("The intersection is empty or OCC returned no valid solid"); @@ -522,7 +523,7 @@ Solid intersect_rsolid(const std::vector& solids) { result_solid.apply_tag("solid.boolean.intersect", false); FeatureRecord rec(kIntersectOp); - rec.param("input_count", Value(static_cast(solids.size()))); + rec.param("input_count", Value(static_cast(solids.size()))).param("clean", Value(clean)); add_operands(rec, solids); FinalizeSpec spec; spec.record = std::move(rec).take(); diff --git a/src/cpp/src/ops/features/executors.cpp b/src/cpp/src/ops/features/executors.cpp index d3dcd53d..555fe075 100644 --- a/src/cpp/src/ops/features/executors.cpp +++ b/src/cpp/src/ops/features/executors.cpp @@ -154,17 +154,19 @@ void register_booleans(reactive::ExecutorRegistry& registry) { return one(union_rsolid(rest(in, 0, in.size()), options)); }); registry.add("make_cut_rsolid", [](Params p, Inputs in, const ScalarValues&) { - return one( - cut_rsolid(rest(in, 0, in.size()), {flag(p, "skip_non_intersecting"), text(p, "tracking_policy")})); + CutOptions options{flag(p, "skip_non_intersecting"), text(p, "tracking_policy")}; + options.clean = flag(p, "clean"); + return one(cut_rsolid(rest(in, 0, in.size()), options)); }); registry.add("make_cut_rsolid_list", [](Params p, Inputs in, const ScalarValues&) { CutOptions options{flag(p, "skip_non_intersecting"), text(p, "tracking_policy")}; + options.clean = flag(p, "clean"); const Value* order = p.find("piece_order"); // absent on records made before ordered pieces options.order_pieces = order != nullptr && order->is_string() && order->as_string() == "centroid"; return many(cut_rsolid_list(rest(in, 0, in.size()), options)); }); - registry.add("make_intersect_rsolid", [](Params, Inputs in, const ScalarValues&) { - return one(intersect_rsolid(rest(in, 0, in.size()))); + registry.add("make_intersect_rsolid", [](Params p, Inputs in, const ScalarValues&) { + return one(intersect_rsolid(rest(in, 0, in.size()), flag(p, "clean"))); }); registry.add("make_boolean_rsolidlist", [](Params p, Inputs in, const ScalarValues&) { const auto left = static_cast(param(p, "left_count").as_int()); diff --git a/src/cpp/src/product/ops_projection.cpp b/src/cpp/src/product/ops_projection.cpp index 85f9777d..1a08de93 100644 --- a/src/cpp/src/product/ops_projection.cpp +++ b/src/cpp/src/product/ops_projection.cpp @@ -17,7 +17,7 @@ namespace { // Every Part body below `item`, placed by `placement` composed with the component chain. void collect_placed_solids(const Item& item, const Placement& placement, std::vector& out) { if (const auto* part = std::get_if(&item)) { - for (const Body& body : (*part)->bodies()) { // every body of a multi-body part, in order + for (const Body& body : (*part)->bodies()) { // the Part's one body out.emplace_back(kernel::place_shape(body.solid().wrapped(), placement.origin(), placement.x_axis(), placement.y_axis(), placement.z_axis())); } diff --git a/src/cpp/src/product/ops_structure.cpp b/src/cpp/src/product/ops_structure.cpp index 9d4f72ff..8674bf54 100644 --- a/src/cpp/src/product/ops_structure.cpp +++ b/src/cpp/src/product/ops_structure.cpp @@ -122,36 +122,6 @@ PartPtr make_part_rpart(const std::string& part_id, const Solid& body, const Opt }); } -PartPtr make_part_rpart(const std::string& part_id, const std::vector& bodies, const OptionalString& name) { - return guard_operation("make_part_rpart", [&] { - auto part = std::make_shared(part_id, bodies, name); - reserve_semantic_id("part", part->part_id()); - record_value( - [&] { - Value params = Value::object(); - params.set("part_id", part->part_id()); - params.set("name", optional_name(part->name())); - Value rows = Value::array(); - Value refs = Value::array(); - for (const Body& body : part->bodies()) { - rows.push_back(body.to_dict()); - const Value body_refs = input_refs(body.solid()); - for (const Value& ref : body_refs.as_array()) - refs.push_back(ref); - } - params.set("bodies", std::move(rows)); - params.set("input_refs", std::move(refs)); - OpRecord record = op_record("make_part_rpart", std::move(params)); - for (const Body& body : part->bodies()) - record.inputs.push_back(OpInput{body.solid(), "body"}); - record.semantic_delta = semantic_created("Part", part->part_id(), part->to_dict()); - return record; - }, - part); - return part; - }); -} - PartPtr assign_material_rpart(const Part& part, const Material& material) { return guard_operation("assign_material_rpart", [&] { auto result = std::make_shared(part.with_material(material)); diff --git a/src/cpp/src/product/part.cpp b/src/cpp/src/product/part.cpp index fa8cbdba..1f570fea 100644 --- a/src/cpp/src/product/part.cpp +++ b/src/cpp/src/product/part.cpp @@ -109,15 +109,17 @@ Part::Part(std::string part_id, std::vector bodies, OptionalString name, s name_(clean_name(name)), material_(std::move(material)), connectors_(unique_connectors(std::move(connectors))) { - if (bodies_.size() > 1 && !connectors_.empty()) - throw ValueError("connectors on a multi-body part are not supported yet (part '" + part_id_ + "')"); + // A Part is one product item: one solid, so that it can be placed, mated and given connectors as a whole. Several + // solids that exist side by side while a model is built (a tool and its target) are loose values, not a Part. + if (bodies_.size() != 1) { + throw ValueError("part '" + part_id_ + "' would hold " + std::to_string(bodies_.size()) + " solids (" + + joined_ids(bodies_) + + "); a Part holds exactly one solid. Make one Part per solid and put them in an Assembly, or " + "union / cut them into one solid first"); + } } const Solid& Part::body() const { - if (bodies_.size() != 1) { - throw TypeError("part '" + part_id_ + "' has " + std::to_string(bodies_.size()) + " bodies (" + - joined_ids(bodies_) + "); read Part.bodies or Part.get_body(body_id)"); - } return bodies_.front().solid(); } @@ -198,8 +200,8 @@ Value Part::to_dict() const { for (const Connector& connector : connectors_) rows.push_back(connector.to_dict()); out.set("connectors", std::move(rows)); - // a one-body Part keeps the dict it always had, unless its body has a name or an appearance - if (bodies_.size() > 1 || !plain_default_body(bodies_.front())) { + // a Part keeps the dict it always had, unless its body has a name or an appearance + if (!plain_default_body(bodies_.front())) { Value bodies = Value::array(); for (const Body& body : bodies_) bodies.push_back(body.to_dict()); diff --git a/src/cpp/src/semantic/tracking_booleans.cpp b/src/cpp/src/semantic/tracking_booleans.cpp index fa51049c..2e326b1d 100644 --- a/src/cpp/src/semantic/tracking_booleans.cpp +++ b/src/cpp/src/semantic/tracking_booleans.cpp @@ -54,10 +54,12 @@ std::string input_role(std::size_t index) { // body op tool, with history. `Solid(result)` of the Python SDK is the first // solid of the kernel's answer (empty when there is none); the history filter // is the whole answer. -TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, const Solid& tool, const char* failure) { +TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, const Solid& tool, const char* failure, + bool clean) { return occt_guard([&] { kernel::BooleanOptions options; options.fill_history = true; + options.clean = clean; const kernel::BooleanResult run = kernel::run_boolean(op, body.wrapped(), {tool.wrapped()}, options); if (!run.done) throw ValueError(failure); @@ -87,12 +89,13 @@ TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, cons } // namespace -TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool) { - return track_boolean(kernel::BooleanOp::Cut, body, tool, "Boolean cut failed: OCC build did not complete"); +TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool, bool clean) { + return track_boolean(kernel::BooleanOp::Cut, body, tool, "Boolean cut failed: OCC build did not complete", clean); } -TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool) { - return track_boolean(kernel::BooleanOp::Common, body, tool, "Boolean intersect failed: OCC build did not complete"); +TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool, bool clean) { + return track_boolean(kernel::BooleanOp::Common, body, tool, "Boolean intersect failed: OCC build did not complete", + clean); } TrackedBooleanResult tracked_union(const Solid& body, const Solid& tool, bool glue, double tol) { diff --git a/src/cpp/tests/cpp/std/test_chain_bearing.cpp b/src/cpp/tests/cpp/std/test_chain_bearing.cpp index cfa728b4..8ed582a9 100644 --- a/src/cpp/tests/cpp/std/test_chain_bearing.cpp +++ b/src/cpp/tests/cpp/std/test_chain_bearing.cpp @@ -11,7 +11,7 @@ using doctest::Approx; TEST_CASE("sprocket: ISO 606 12B candidate") { const Solid sprocket = make_roller_chain_sprocket_rsolid(18, 19.05, 12.07, 11.1, 10.2); CHECK(sprocket.volume() == Approx(98201.67369173263).epsilon(1e-6)); - CHECK(sprocket.faces().size() == 54); + CHECK(sprocket.faces().size() == 39); // 54 before cut unified the faces a cylinder seam split (issue #88) const Value meta = sprocket.get_metadata("std.chain.roller_sprocket"); CHECK(meta.find("n_teeth")->as_int() == 18); CHECK(number(meta, "pitch_radius") == Approx(19.05 / (2.0 * std::sin(kPi / 18)))); diff --git a/src/cpp/tests/golden/std_sprocket.json b/src/cpp/tests/golden/std_sprocket.json index e703e28a..784c0a06 100644 --- a/src/cpp/tests/golden/std_sprocket.json +++ b/src/cpp/tests/golden/std_sprocket.json @@ -20,8 +20,8 @@ 59.38481593313661, 11.1 ], - "edges": 156, - "faces": 54, + "edges": 111, + "faces": 39, "tags": [ "solid.boolean.cut" ], diff --git a/src/cpp/tests/golden/std_sprocket_unbored.json b/src/cpp/tests/golden/std_sprocket_unbored.json index f305a0e0..d3b67db0 100644 --- a/src/cpp/tests/golden/std_sprocket_unbored.json +++ b/src/cpp/tests/golden/std_sprocket_unbored.json @@ -20,8 +20,8 @@ 27.35284634516466, 5.0 ], - "edges": 99, - "faces": 35, + "edges": 72, + "faces": 26, "tags": [ "solid.boolean.cut" ], diff --git a/src/cpp/tests/python/test_ops_features_booleans.py b/src/cpp/tests/python/test_ops_features_booleans.py index 81527eac..03d27bb9 100644 --- a/src/cpp/tests/python/test_ops_features_booleans.py +++ b/src/cpp/tests/python/test_ops_features_booleans.py @@ -118,6 +118,6 @@ def info(face): def test_signatures_match_python(): assert parameter_names(scad.union_rsolid) == ["solids", "clean", "glue", "tol", "tracking_policy"] # *solids - # on_split: a cut that splits the body returns its pieces by default (multi-body parts), "error" refuses - assert parameter_names(scad.cut_rsolid) == ["solids", "skip_non_intersecting", "tracking_policy", "on_split"] + # on_split: a cut that splits the body returns its pieces by default (notebook values; a Part holds one solid), "error" refuses + assert parameter_names(scad.cut_rsolid) == ["solids", "skip_non_intersecting", "tracking_policy", "on_split", "clean"] assert parameter_names(scad.boolean_rsolidlist) == ["left", "right", "operation"] diff --git a/src/simplecadapi/__init__.py b/src/simplecadapi/__init__.py index 68610e44..5ce7048f 100644 --- a/src/simplecadapi/__init__.py +++ b/src/simplecadapi/__init__.py @@ -414,15 +414,7 @@ def __getattr__(name: str) -> Any: from .errors import SimpleCADError from simplecadapi._glue.session import GraphSession from .operators import render_screenshot_rpath -from .product.bodies import ( - BodyMass, - BodyOverlap, - BodyOverlapWarning, - MassReport, - inspect_body_overlaps_rlist, - measure_part_mass_rmassreport, - part_shape, -) +from .product.bodies import BodyMass, MassReport, measure_part_mass_rmassreport from .product.placement import Placement from .product.capture import CaptureResult, capture from .product.packages import ( @@ -667,7 +659,6 @@ def notebook_id() -> str: "solve_assembly_constraints_rassembly", "measure_constraint_residual_rconstraintresidual", "measure_part_mass_rmassreport", - "inspect_body_overlaps_rlist", "inspect_assembly_constraints_rconstraintreport", # Sketch construction and constraints "add_point_rsketch", diff --git a/src/simplecadapi/_glue/docstrings.json b/src/simplecadapi/_glue/docstrings.json index 47f776db..8cbf5358 100644 --- a/src/simplecadapi/_glue/docstrings.json +++ b/src/simplecadapi/_glue/docstrings.json @@ -28,7 +28,7 @@ "Material": "Material definition assigned to a Part through `assign_material_rpart`.", "OperationGraph": "A directed acyclic graph of CAD operations.\n\nThe graph tracks nodes (operations) and edges (data-flow dependencies).\nIt supports topological ordering, upstream/downstream queries, and\nroot/leaf enumeration.\n\nUsage::\n\n g = OperationGraph()\n e1 = g.add_node(\"make_line_redge\", {\"start\": (0, 0, 0), \"end\": (1, 0, 0)})\n e2 = g.add_node(\"make_line_redge\", {\"start\": (1, 0, 0), \"end\": (1, 1, 0)})\n wire = g.add_node(\"make_wire_from_edges_rwire\", {\"edge_count\": 2}, inputs=[e1, e2])\n assert wire.node_id == g.leaf_nodes()[0].node_id\n\nA *namespace* prefixes every id the graph allocates\n(``_node_00000001``). Graphs that share a ``graph_id`` but\nallocate ids independently, such as the per-cell graphs of a notebook,\nuse distinct namespaces so their nodes can later meet in one graph\n(:meth:`adopt`) without id clashes.", "OperationNode": "A single node in the operation DAG.\n\nAttributes:\n node_id: Unique identifier within the graph.\n op: Operation type (e.g. ``\"make_line_redge\"``, ``\"make_cut_rsolid\"``).\n params: Serialisable parameters for re-creation.\n inputs: Upstream nodes whose outputs feed into this node.\n context: Work-plane / coordinate-system snapshot taken at creation time.\n output_count: Number of output shapes this node produces (usually 1).\n topo_delta: Topological change set (may be ``None`` for simple primitives).\n tags: Free-form labels for annotation.\n source: Best-effort Python source provenance; ignored by replay.\n expressions: The expressions ``param_exprs`` refers to.\n frame: The coordinate frame current when the node was recorded.\n\n``expressions`` and ``frame`` duplicate what the recording session keeps\nin its expression and frame graphs. They travel with the node so a node\nthat outlives its session (a notebook cell's result, restored from the\ncell cache) can still be registered in a new one. They are excluded from\nequality and from :meth:`OperationGraph.to_dict`.\n\nNodes pickle and deep-copy without recursion (see :meth:`__reduce__`).", - "Part": "Product item holding one Solid (``body``) or several named bodies (``bodies``).\n\nA one-body Part is the common case: ``part.body`` is its Solid. A multi-body\nPart keeps several solids apart inside one product item (a case and its lid\nmodelled side by side, the pieces of a split cut): ``part.bodies`` lists them\nas ``Body`` values in order, ``part.get_body(body_id)`` finds one, and\n``part.body`` raises ``TypeError``. Bodies of one Part are never placed\nrelative to each other and carry no joints; use an Assembly for that.", + "Part": "Product item holding exactly one Solid (``body``).\n\nA Part is the unit an Assembly places, mates and gives connectors to, so it is one\nsolid. ``part.body`` is its Solid; ``part.bodies`` is that body as a one-element tuple\nof ``Body`` values (a ``Body`` carries the display name, colour and opacity,\n``set_body_appearance_rpart``). ``body`` also takes a ``Body``. Several solids are\nrefused (ValueError): make one Part per solid in an Assembly, or union / cut them\ninto one solid first.", "Placement": "Right-handed placement mapping child-local coordinates to parent coordinates.", "PublicConnectorRef": "Public alias for an existing connector on a direct child component.", "ScalarExprBase": "Base class for scalar expression nodes.", @@ -96,7 +96,7 @@ "add_revolute_constraint_rassembly": "Constrain two connectors as a revolute axis pair.", "and_": "Combine predicates so all of them must match.", "apply_tag": "Attach a local user tag with continuation/fragment lineage policy.", - "apply_tag_rselection": "Return a semantic shape view with a canonical tag assignment.\n\nA tag is also how a face, an edge or a vertex gets a name people read: there is no\nsecond naming system. Tags resolve through the recorded history, so a tagged face\nkeeps its name through later features (``ql.tag(...)`` and\n``select_faces_by_tag(...)`` find it again); the editor's \"name a face\" form is this\noperation with the picked faces as ``targets``.\n\nArgs:\n scope: The shape the tag is assigned in (a Solid, usually the body).\n targets: What to tag: a ``ql`` selection (``scad.ql.faces().where(...)``) or a\n list of sub-shapes of ``scope``.\n tag: The name, a normalized token such as ``\"lid_seat\"`` or\n ``\"role.mounting_surface\"``.\n topology_propagation: ``\"local\"`` tags the targets only; ``\"downward\"`` also\n their edges and vertices.\n lineage_policy: How the tag follows the targets through later operations\n (default ``\"continuation_fragment\"``: kept on continued faces; the\n pieces of a split face carry ``.split1`` .. ``.split`` instead,\n see ``list_tag_splits``).\n\nReturns:\n AnyShape: A view of ``scope`` with the tag assigned (the same geometry).\n\nRaises:\n SimpleCADError: The selection resolved nothing, a target is not inside the\n scope, or the tag is malformed.\n\nUsage:\n ``block = scad.apply_tag_rselection(block, scad.ql.faces().where(scad.ql.prop(\"geom.normal.z\", \">=\", 0.999)), \"lid_seat\")``\n names the top face; ``scad.ql.faces().where(scad.ql.tag(\"lid_seat\"))`` selects it\n later. A body of a Part is named with ``set_body_appearance_rpart(part, body_id, name=...)``.", + "apply_tag_rselection": "Return a semantic shape view with a canonical tag assignment.\n\nA tag is also how a face, an edge or a vertex gets a name people read: there is no\nsecond naming system. Tags resolve through the recorded history, so a tagged face\nkeeps its name through later features (``ql.tag(...)`` and\n``select_faces_by_tag(...)`` find it again); the editor's \"name a face\" form is this\noperation with the picked faces as ``targets``.\n\nArgs:\n scope: The shape the tag is assigned in (a Solid, usually the body).\n targets: What to tag: a ``ql`` selection (``scad.ql.faces().where(...)``) or a\n list of sub-shapes of ``scope``.\n tag: The name, a normalized token such as ``\"lid_seat\"`` or\n ``\"role.mounting_surface\"``.\n topology_propagation: ``\"local\"`` tags the targets only; ``\"downward\"`` also\n their edges and vertices.\n lineage_policy: How the tag follows the targets through later operations\n (default ``\"continuation_fragment\"``: kept on continued faces; the\n pieces of a split face carry ``.split1`` .. ``.split`` instead,\n see ``list_tag_splits``).\n\nReturns:\n AnyShape: A view of ``scope`` with the tag assigned (the same geometry).\n\nRaises:\n SimpleCADError: The selection resolved nothing, a target is not inside the\n scope, or the tag is malformed.\n\nUsage:\n ``block = scad.apply_tag_rselection(block, scad.ql.faces().where(scad.ql.prop(\"geom.normal.z\", \">=\", 0.999)), \"lid_seat\")``\n names the top face; ``scad.ql.faces().where(scad.ql.tag(\"lid_seat\"))`` selects it\n later. The body of a Part is named with ``set_body_appearance_rpart(part, name=...)``.", "apply_tracking_tags_to_delta": "Apply evidence-gated tracking projections for one operation result.", "assert_blend_volume_monotonic": "Mechanism E exit check: an all-convex blend may only remove material.\n\nObserved kernel behavior (and the reason this check exists): filleting\nevery edge of a 10 mm cube with r=6 *succeeds* and returns a solid whose\nvolume (1036.35) exceeds the input (1000) — geometrically impossible\noutput that the kernel hands back without a word. Fillet and chamfer\noutputs on convex edges are subsets of the input solid, so a volume\nincrease beyond measurement noise means the kernel silently produced\ngarbage; we name it instead of returning it.\n\nThe increase check only fires for provably all-convex selections:\nblending a *concave* edge legitimately adds material (the corner fill —\ne.g. a bolt's underhead fillet gains exactly the quarter-circle deficit\ntimes its circumference), and undeterminable edges mean the check\ncannot prove impossibility. A non-positive result volume is garbage\nregardless of convexity and always fires.\n\nThe check runs on the success path, so it must stay cheap: two GProp\nvolume evaluations plus one side-probe per selected edge. Raised as a\n``BlendVolumeViolation`` so the caller's ``_wrap_blend_failure`` handler\npromotes the measured facts and layers the standard blend diagnosis\n(probes, evidence render) on top.", "assign_material_rpart": "Assign a Material to a Part and return the updated Part.", @@ -145,7 +145,7 @@ "constrain_tangent_rsketch": "Constrain supported curves to be tangent on an explicit branch.\n\n``at_a``/``at_b`` select ``\"start\"`` or ``\"end\"`` for arc and\nB-spline endpoint tangency. Circle-circle tangency accepts\n``mode=\"external\"`` or ``mode=\"internal\"``.", "constrain_vertical_rsketch": "Constrain a sketch line to be vertical.", "convert_value": "Convert a finite numeric value between dimensionally compatible units.", - "cut_rsolid": "Compute the boolean difference of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, tools with no meaningful intersection are\n ignored for interactive convenience. Graph replay records this flag and\n should use False for strict diagnostic workflows.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n on_split: What a cut that splits the body (a slot through a bar) returns.\n ``\"pieces\"`` (default): every piece, as ``list[Solid]`` ordered by centroid\n (x, then y, then z), recorded like ``cut_rsolid_list``. ``\"error\"``: raise,\n for code that needs exactly one Solid.\n\nReturns:\n Solid: The cut result solid, when the result is one piece.\n list[Solid]: The pieces (a ``scad.Pieces``), when the cut split the body\n (``on_split=\"pieces\"``). Operations that take one shape run on every piece\n (``cut_rsolid(pieces, tool)``, ``translate_shape(pieces, ...)``) and return\n ``Pieces``; a selector's cardinality is checked over all pieces. Pass them on as bodies: ``make_part_rpart(part_id=..., body=pieces)`` makes the\n bodies ``body_1 .. body_n``, ``bodies={\"bar\": pieces}`` makes ``bar_1 .. bar_n``.\n\nRaises:\n SimpleCADError: The cut produced no solid, or (``on_split=\"error\"``) split\n the body. Use ``cut_rsolid_list`` to always get a list.\n\nUsage:\n Accepts a base solid followed by one or more tool solids, including nested\n sequences, and returns a single `Solid`.", + "cut_rsolid": "Compute the boolean difference of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, tools with no meaningful intersection are\n ignored for interactive convenience. Graph replay records this flag and\n should use False for strict diagnostic workflows.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n clean: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n on_split: What a cut that splits the body (a slot through a bar) returns.\n ``\"pieces\"`` (default): every piece, as ``list[Solid]`` ordered by centroid\n (x, then y, then z), recorded like ``cut_rsolid_list``. ``\"error\"``: raise,\n for code that needs exactly one Solid.\n\nReturns:\n Solid: The cut result solid, when the result is one piece.\n list[Solid]: The pieces (a ``scad.Pieces``), when the cut split the body\n (``on_split=\"pieces\"``). Operations that take one shape run on every piece\n (``cut_rsolid(pieces, tool)``, ``translate_shape(pieces, ...)``) and return\n ``Pieces``; a selector's cardinality is checked over all pieces. They are notebook values: a Part holds exactly one solid, so make one Part of each piece\n (an Assembly of them is the several-piece result), not ``make_part_rpart(body=pieces)``.\n\nRaises:\n SimpleCADError: The cut produced no solid, or (``on_split=\"error\"``) split\n the body. Use ``cut_rsolid_list`` to always get a list.\n\nUsage:\n Accepts a base solid followed by one or more tool solids, including nested\n sequences, and returns a single `Solid`.", "diagnose_blend_failure": "Classify a fillet/chamfer failure; never raises.\n\nProbe order (cheapest explanation wins):\n\n1. Tangent adjacency — the two faces meeting at a selected edge are\n already tangent (dihedral ~180°), so the blend surface degenerates to\n zero width regardless of size.\n2. Room — blend size exceeds the distance from the edge to the opposite\n boundary of an adjacent face.\n3. Interaction — per-edge diagnostic rerun (``retry_single``, capped at\n 64 edges) isolates edges that fail alone; when every edge passes\n alone but the joint blend fails, opposite blends on a shared face\n meeting tangentially (2×size >= face room) is the explanation.", "diagnose_boolean_failure": "Classify a boolean failure from its operands; never raises.\n\nModel: operands within ``tol`` of each other form one connected cluster\n(transitively). More than one cluster means the union is genuinely\nseparated — report the nearest inter-cluster pair. A single cluster that\nstill failed to fuse contains a touch-without-overlap pair — report it as\nnon-manifold contact. Returns None when no pair explains the failure.", "diff_sketch_rsketchdiff": "Report what an edit did: entity and constraint ids created, removed or changed between two sketches.\n\nReturns a dict with ``created``, ``removed``, ``changed`` (entity ids, points included) and\n``created_constraints``, ``removed_constraints``, ``changed_constraints``. Not recorded in the graph.", @@ -180,7 +180,7 @@ "inspect_sketch_rsketchprofiles": "List the profiles a sketch offers: the indices ``make_face_from_sketch_rface`` and ``make_wire_from_sketch_rwire`` take.\n\nReturns ``{\"profiles\": [...], \"unusable\": [...]}``. Each profile is ``{index, id, kind, closed, entity_ids, point_ids, reversed}``: ``kind`` is ``circle``, ``edge_loop`` or ``edge_chain``; ``entity_ids`` are in walking order and ``reversed`` says which edges are walked from end to start. ``unusable`` words why an edge set is no profile (a branch point carrying more than two edges, for one). A free circle is a profile; a loop is a connected set of edges whose points each carry exactly two. Not recorded in the graph; pass ``strict=False`` for a sketch that does not solve cleanly.", "inspect_sketch_rsketchresult": "Inspect sketch constraints by running the solver without recording graph nodes.", "internal_tag_binding": "Create a source-marked binding for transitional internal annotations.", - "intersect_rsolid": "Compute the boolean intersection of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing.\n\nReturns:\n Solid: The overlap region as a single solid.\n\nUsage:\n Accepts one or more solids, including nested sequences, and returns a single\n `Solid`. If the inputs do not overlap meaningfully, the API raises a clear\n error instead of returning an empty list.", + "intersect_rsolid": "Compute the boolean intersection of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing.\n clean: When True (the default) the faces and edges the intersection leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n\nReturns:\n Solid: The overlap region as a single solid.\n\nUsage:\n Accepts one or more solids, including nested sequences, and returns a single\n `Solid`. If the inputs do not overlap meaningfully, the API raises a clear\n error instead of returning an empty list.", "is_normalized_tag": "Check whether a tag matches the normalized dot-token format.", "isolated_recording": "Run a nested definition build as if it were called at top level.\n\nThe caller's session is hidden (no active session, recording not\nsuspended) and the world coordinate system is current, so the nested\nbuild records only into its own session and does not depend on where it\nwas called from. Everything is restored on exit.", "legacy_tag_binding": "Import one flat compatibility token without inventing assignment intent.", @@ -233,7 +233,7 @@ "make_line_redge": "Create a straight edge between two points.", "make_material_rmaterial": "Create a material definition for later Part assignment.\n\nMaterial is deliberately separate from `make_part_rpart(...)`; the only\ncorrect workflow is to create a material and then assign it to a Part with\n`assign_material_rpart(...)`.", "make_nut_rsolid": "Create a nut along +Z with its bottom face on the Z=0 plane.\n\n``nut_style`` accepts ``hex``, ``square``, ``round``, and ``knurled``.\nThrough and blind holes are supported. Modeled thread detail adds internal\nV or trapezoidal helical teeth to a major-diameter hole.", - "make_part_rpart": "Wrap one Solid, or several named solids, as a semantic Part.\n\nArgs:\n part_id: Stable identifier of the part (``scad.notebook_id()`` in a notebook).\n body: The Solid of a one-body Part. The pieces of a split cut (a list of\n Solids) become the bodies ``body_1 .. body_n``.\n name: Optional display name.\n bodies: Instead of ``body``: several solids kept apart in one Part, as a\n mapping ``body_id -> Solid`` (in its order) or a sequence of ``Body``\n values. A mapping value may be the pieces of a split cut (a list of\n Solids): they become ``_1 .. _n``. Body ids are\n identifiers, unique within the part. Overlapping bodies are allowed;\n building the definition warns (``BodyOverlapWarning``).\n\nReturns:\n Part: The new Part. ``part.bodies`` lists its bodies; ``part.body`` is the\n Solid of a one-body Part.\n\nRaises:\n SimpleCADError: Neither or both of ``body`` and ``bodies`` were given, a body\n is not a Solid, a body id is malformed or repeated, or the part_id is\n already used.\n\nUsage:\n ``scad.make_part_rpart(part_id=scad.notebook_id(), body=bracket)`` for one solid;\n ``scad.make_part_rpart(part_id=scad.notebook_id(), bodies={\"case\": case, \"lid\": lid})``\n for a case and its lid kept as two bodies of one part. Material applies to every\n body. Connectors are not supported on multi-body parts yet. Union the solids\n first when they must become one body.", + "make_part_rpart": "Wrap one Solid as a semantic Part. A Part holds exactly one solid.\n\nArgs:\n part_id: Stable identifier of the part (``scad.notebook_id()`` in a notebook).\n body: The Solid of the Part. A ``Body`` (a solid with a display name, colour and\n opacity) and a list of exactly one piece of a split cut are also taken.\n Several solids, or a list of several pieces, are refused.\n name: Optional display name.\n\nReturns:\n Part: The new Part. ``part.body`` is its Solid; ``part.bodies`` is that body as a\n one-element tuple.\n\nRaises:\n SimpleCADError: The body is not a Solid, more than one solid was given (a ValueError\n that says to make one Part per solid in an Assembly, or to union / cut them to one\n solid), or the part_id is already used.\n\nUsage:\n ``scad.make_part_rpart(part_id=scad.notebook_id(), body=bracket)``. Several solids\n are not one Part: make one Part per solid and put them in an Assembly (identity\n placements when they only sit together), or union / cut them into one solid first.\n Solids that sit side by side while a model is built (a tool next to the body it will\n cut) are ordinary notebook values, not a Part.", "make_periodic_spline_rwire": "Create a closed periodic spline wire interpolating the supplied points.", "make_placement_connector_rconnector": "Create a connector anchored to an explicit local placement frame.\n\nUse this when a datum should be defined by a stable coordinate frame\ninstead of a selected BREP face, edge, or vertex.", "make_placement_rplacement": "Create a canonical right-handed placement in the active workplane.", @@ -347,10 +347,9 @@ "validate_placement_ticks": "Validate a persisted tick frame with exact integer arithmetic.", "value": "Build a value key extractor for ordering and projection in QL.\n\nArgs:\n path: Property or metadata path to resolve.\n default: Fallback value when the path is missing.\n\nReturns:\n Serializable key function for `Query.order_by(...)`.", "var": "Create a physical or legacy scalar variable.\n\n``tolerance=0.1`` declares a symmetric ``+/-0.1`` tolerance. Use a\n``(lower_deviation, upper_deviation)`` pair for an asymmetric tolerance.\n``tolerance_unit`` defaults to ``unit`` when a nominal unit is declared.\nValues are converted to canonical CAD units only when evaluated, so the\ndeclaration and serialized expression node preserve the user's units.\n\nVariables without ``unit`` retain legacy unitless behavior. A unit-aware\nexpression cannot mix declared-unit variables with legacy variables.", - "cut_rsolid_list": "Compute the boolean difference of solids and keep every piece.\n\nA cut can split the body (a slot through a bar leaves two parts) or consume it\n(a tool that encloses the whole body leaves nothing). ``cut_rsolid`` refuses\nsuch a result because it returns exactly one ``Solid``; this operator returns\nthem all. A tool is cut from every piece it meets, so several tools may split\na body into several parts.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, a tool that meets none of the current\n pieces is ignored; when False it raises.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n\nReturns:\n list[Solid]: Every piece of the cut, in kernel order; empty when the tools\n consume the base.\n\nRaises:\n SimpleCADError: An input is not a valid Solid, or the kernel could not\n compute the cut.\n\nUsage:\n ``pieces = cut_rsolid_list(bar, slot)`` returns both halves of a bar cut by\n a through slot. Use ``cut_rsolid`` when the result must be one solid.\n", - "Body": "One named solid of a Part.\n\n``Body(body_id, solid, name=None, color=None, opacity=None)``: ``body_id`` is an\nidentifier unique within the part, ``solid`` the body's Solid, ``name`` an optional\ndisplay name, ``color`` an optional display colour (RGB floats in [0, 1]) and\n``opacity`` an optional display opacity in [0, 1]. A one-body Part has one Body with\nthe id ``\"body\"``. The colour overrides the Part's material colour for display only;\nset it on an existing Part with ``set_body_appearance_rpart``.", - "measure_part_mass_rmassreport": "Measure the volume, mass and centre of mass of every body of a Part, and of the Part as a whole.\n\nArgs:\n part: The Part to measure (one body or several).\n density: Density to use instead of the material's; ``None`` uses the\n Part's material, and without a material density every mass is ``None``.\n density_unit: Unit of ``density``: ``kg/m^3`` (default), ``g/cm^3``,\n ``kg/mm^3`` or ``g/mm^3``.\n\nReturns:\n MassReport: ``bodies`` (one ``BodyMass`` per body, in body order: ``body_id``,\n ``volume_mm3``, ``mass_kg``, ``centroid_mm``) and the totals of the part\n (``volume_mm3``, ``mass_kg``, volume-weighted ``centroid_mm``).\n\nRaises:\n SimpleCADError: The value is not a Part, or the density or its unit is invalid.\n\nUsage:\n ``scad.measure_part_mass_rmassreport(case).body(\"lid\").mass_kg`` is the mass of\n the lid body; ``.mass_kg`` the mass of the whole part. Every body has the\n Part's material.", - "inspect_body_overlaps_rlist": "List the pairs of bodies of a Part that share volume.\n\nOverlapping bodies are allowed (a Part does not fuse them), but mass counts the\nshared volume once per body and STEP holds both copies. Bodies that only touch\n(a face, an edge or a point in common) do not overlap.\n\nArgs:\n part: The Part to inspect; a one-body Part has no overlaps.\n min_volume_mm3: Shared volume below which a pair is not reported.\n\nReturns:\n list[BodyOverlap]: ``(first, second, volume_mm3)`` per overlapping pair, in body order.\n\nRaises:\n SimpleCADError: The value is not a Part.\n\nUsage:\n ``scad.inspect_body_overlaps_rlist(case)`` before export. Building the part's\n definition (a notebook run, ``@part``, a package) warns with ``BodyOverlapWarning``\n for the same pairs.", + "cut_rsolid_list": "Compute the boolean difference of solids and keep every piece.\n\nA cut can split the body (a slot through a bar leaves two parts) or consume it\n(a tool that encloses the whole body leaves nothing). ``cut_rsolid`` refuses\nsuch a result because it returns exactly one ``Solid``; this operator returns\nthem all. A tool is cut from every piece it meets, so several tools may split\na body into several parts.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, a tool that meets none of the current\n pieces is ignored; when False it raises.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n clean: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n\nReturns:\n list[Solid]: Every piece of the cut, in kernel order; empty when the tools\n consume the base.\n\nRaises:\n SimpleCADError: An input is not a valid Solid, or the kernel could not\n compute the cut.\n\nUsage:\n ``pieces = cut_rsolid_list(bar, slot)`` returns both halves of a bar cut by\n a through slot. Use ``cut_rsolid`` when the result must be one solid.\n", + "Body": "The solid of a Part, with its display name, colour and opacity.\n\n``Body(body_id, solid, name=None, color=None, opacity=None)``: ``body_id`` is an\nidentifier (``\"body\"`` for the body of a Part), ``solid`` the body's Solid, ``name`` an optional\ndisplay name, ``color`` an optional display colour (RGB floats in [0, 1]) and\n``opacity`` an optional display opacity in [0, 1]. A one-body Part has one Body with\nthe id ``\"body\"``. The colour overrides the Part's material colour for display only;\nset it on an existing Part with ``set_body_appearance_rpart``.", + "measure_part_mass_rmassreport": "Measure the volume, mass and centre of mass of a Part.\n\nArgs:\n part: The Part to measure.\n density: Density to use instead of the material's; ``None`` uses the\n Part's material, and without a material density every mass is ``None``.\n density_unit: Unit of ``density``: ``kg/m^3`` (default), ``g/cm^3``,\n ``kg/mm^3`` or ``g/mm^3``.\n\nReturns:\n MassReport: ``bodies`` (the Part's one ``BodyMass``: ``body_id``, ``volume_mm3``,\n ``mass_kg``, ``centroid_mm``) and the totals of the part (``volume_mm3``,\n ``mass_kg``, ``centroid_mm``).\n\nRaises:\n SimpleCADError: The value is not a Part, or the density or its unit is invalid.\n\nUsage:\n ``scad.measure_part_mass_rmassreport(bracket).mass_kg`` is the mass of the part.\n The part's material gives the density unless ``density`` is passed.", "inspect_curve_rcurvedata": "Read the NURBS data of an edge's curve: what the curve edits work on.\n\nReturns ``{type, analytic, editable, conversion, degree, poles, weights, knots, multiplicities, periodic, rational, closed, domain, reversed, parameter_range}``. ``poles`` are points in the current coordinate system, indices are 0-based (the ``moves`` of ``move_curve_poles_redge``); ``parameter_range`` is the edge's own range (where ``insert_curve_knot_redge`` may insert). For a line, circle or other analytic curve ``editable`` is false, ``conversion`` is ``\"convert_to_bspline_redge\"`` and the data is what that conversion would produce. Not recorded in the graph.", "inspect_surface_rsurfacedata": "Read the NURBS data of a face's surface: what the surface edits work on.\n\nReturns ``{type, analytic, editable, conversion, u_degree, v_degree, poles, weights, u_knots, v_knots, u_multiplicities, v_multiplicities, u_periodic, v_periodic, rational, domain, reversed, trimmed, uv_bounds}``. ``poles`` is a grid of rows along U, each row running along V (as ``make_bezier_surface_rface`` takes it), in the current coordinate system; indices are 0-based ``(row, column)``. ``domain`` is the surface's ``[u1, u2, v1, v2]``, ``uv_bounds`` the face's; ``trimmed`` says whether the face is bounded by anything but the surface's own domain. For a plane, cylinder or other analytic surface ``editable`` is false, ``conversion`` is ``\"convert_to_bspline_rface\"`` and the data is what that conversion would produce. Not recorded in the graph.", "convert_to_bspline_redge": "Convert an edge's curve to a B-spline, so the curve edits can work on it.\n\nThe explicit, recorded step for analytic edges (a line becomes a degree-1 B-spline, a circle or arc a rational quadratic); the shape does not change, its parameter range may. Returns a new Edge; the input is not changed. Recorded: the Edge continues in the node's history, so tags and references follow.", @@ -369,7 +368,7 @@ "rebuild_surface_rface": "Refit a face's surface as a B-spline of the given degrees and pole counts, or with the fewest poles that keep within ``tolerance``.\n\nGive both pole counts or neither. A least-squares fit in the face's own (u, v) parametrisation (over the surface's domain for a B-spline, over the face's bounds otherwise), so the face keeps its trimming. Works on any face. Returns a new Face; the input is not changed. Recorded: the Face and its boundary edges continue in the node's history, so tags and references follow.", "sample_curve_rcurvature": "Sample an edge for a curvature comb: ``count`` points evenly in its parameter range.\n\nReturns ``{parameters, points, tangents, normals, curvatures, max_curvature}`` in the current coordinate system; a normal points to the centre of curvature and is zero where the curve is straight. Not recorded in the graph.", "sample_surface_risocurves": "Sample isocurves of a face, clipped to its trimming.\n\n``u_count`` lines of constant u and ``v_count`` of constant v, evenly inside the face's (u, v) bounds, each sampled at ``samples`` points. Returns ``{u: [{parameter, segments}], v: [...], uv_bounds}``; ``segments`` are the runs of points that lie on the face. Not recorded in the graph.", - "set_body_appearance_rpart": "Set the display colour, opacity and display name of one body of a Part.\n\nA Part is a value: the result is a new Part, the input is unchanged. The colour is an\nappearance only. A body without a colour shows its Part's material colour\n(``Material.color``), and without either it shows the default grey: the body colour\noverrides the material colour for display (the live preview, the package scene, the STEP\nsurface colour of that solid). Mass, density and geometry never read it.\n\nArgs:\n part: The Part (one body or several).\n body_id: The body to change: an id from ``part.body_ids()``. A one-body Part has\n the one body ``\"body\"`` (the default).\n color: Display colour as RGB floats in [0.0, 1.0], e.g. ``(0.8, 0.2, 0.2)``;\n linear RGB, as ``Material.color``, OpenCASCADE (so STEP) and three.js read\n it (the editor's colour picker converts its sRGB ``#rrggbb``). ``None``\n keeps the body's colour.\n opacity: Display opacity in [0.0, 1.0] (1 is opaque). ``None`` keeps it. STEP\n export writes the colour, not the opacity.\n name: Display name of the body (the editor's bodies list, the scene node,\n the STEP solid). ``None`` keeps it. The ``body_id`` never changes.\n reset: Clear the colour, opacity and name first, so the body shows the material\n colour, opaque, under its id; then apply what is given.\n\nReturns:\n Part: The Part with that body's appearance changed; ``part.get_body(body_id).color``,\n ``.opacity`` and ``.name`` read it back.\n\nRaises:\n SimpleCADError: The value is not a Part, the part has no such body, the colour is\n not three numbers in [0, 1], the opacity is outside [0, 1], or the name is empty.\n\nUsage:\n ``case = scad.set_body_appearance_rpart(case, \"lid\", color=(0.9, 0.3, 0.2), name=\"Lid\")``\n paints the lid body of a multi-body part; ``scad.set_body_appearance_rpart(part,\n color=(0.2, 0.4, 0.8))`` paints a one-body part. Recorded and replayed like\n ``assign_material_rpart``. To name a face or an edge for people, tag it\n (``scad.apply_tag_rselection`` / ``ql.tag``): a tag is the name.", + "set_body_appearance_rpart": "Set the display colour, opacity and display name of the body of a Part.\n\nA Part is a value: the result is a new Part, the input is unchanged. The colour is an\nappearance only. A body without a colour shows its Part's material colour\n(``Material.color``), and without either it shows the default grey: the body colour\noverrides the material colour for display (the live preview, the package scene, the STEP\nsurface colour of that solid). Mass, density and geometry never read it.\n\nArgs:\n part: The Part.\n body_id: The body to change: the Part's one body, ``\"body\"`` (the default).\n color: Display colour as RGB floats in [0.0, 1.0], e.g. ``(0.8, 0.2, 0.2)``;\n linear RGB, as ``Material.color``, OpenCASCADE (so STEP) and three.js read\n it (the editor's colour picker converts its sRGB ``#rrggbb``). ``None``\n keeps the body's colour.\n opacity: Display opacity in [0.0, 1.0] (1 is opaque). ``None`` keeps it. STEP\n export writes the colour, not the opacity.\n name: Display name of the body (the editor's bodies list, the scene node,\n the STEP solid). ``None`` keeps it. The ``body_id`` never changes.\n reset: Clear the colour, opacity and name first, so the body shows the material\n colour, opaque, under its id; then apply what is given.\n\nReturns:\n Part: The Part with the body's appearance changed; ``part.get_body(body_id).color``,\n ``.opacity`` and ``.name`` read it back.\n\nRaises:\n SimpleCADError: The value is not a Part, the part has no such body, the colour is\n not three numbers in [0, 1], the opacity is outside [0, 1], or the name is empty.\n\nUsage:\n ``lid = scad.set_body_appearance_rpart(lid, color=(0.9, 0.3, 0.2), name=\"Lid\")``\n paints the part's body (a Part holds one solid, whose body id is ``\"body\"``). Recorded and\n replayed like ``assign_material_rpart``. To name a face or an edge for people, tag it\n (``scad.apply_tag_rselection`` / ``ql.tag``): a tag is the name.", "make_workplane_rworkplane": "Create a recorded workplane from an origin, a normal and an x direction.\n\nThe recorded spelling of ``SimpleWorkplane(origin, normal, x_dir)``: the three vectors are read in the current\ncoordinate system (inside ``with other_wp:`` they are local to it). An ``x_dir`` along the normal is replaced by a\nperpendicular; otherwise x is ``x_dir`` made square to the normal.\n\nArgs:\n origin: The origin of the plane.\n normal: The plane's normal (its local z axis); its length does not matter.\n x_dir: The direction of the local x axis, projected onto the plane.\n\nReturns:\n SimpleWorkplane: a frame in world coordinates (``origin``, ``normal``, ``x_dir``, ``y_dir``, ``plane``) that is\n also a context manager: ``with wp:`` makes it the current coordinate system, ``make_sketch_rsketch(plane=wp)``\n draws on it. It is a value: enter it again in later code, pickle it (the notebook cell cache does).\n\nRaises:\n SimpleCADError: The normal is zero or a value is not finite.\n\nUsage:\n ``wp = scad.make_workplane_rworkplane((0, 0, 40), (0, 1, 0))`` then ``with wp: ...`` or\n ``scad.make_sketch_rsketch(\"rib\", plane=wp)``.\n", "make_workplane_from_face_rworkplane": "Create a workplane on a planar face of the model.\n\nnormal = the face's outward normal (``flip=True`` reverses it); origin = the face's centre, or ``origin``\nprojected onto the face's plane; x = along ``x_edge`` (its direction at its start, projected onto the plane), or\nalong ``x_dir`` (projected), or the x direction of the face's own plane. ``offset`` then moves the origin along\nthe normal (an offset datum plane).\n\nRecorded: the node takes the inputs as ordered references, so a model-JSON replay builds the frame again from the\ngeometry as it is then, and a sketch on the workplane (``plane=wp``) or an operation inside ``with wp:`` follows it.\n\nArgs:\n face: A planar Face (picked with ``ql``, ``get_faces(i)`` or a tag).\n origin: A point (current coordinate system) projected onto the face; ``None`` = the face's centre.\n x_edge: An Edge giving the x direction (usually an edge of the face).\n x_dir: A direction giving x; give ``x_edge`` or ``x_dir``, not both.\n offset: Distance along the normal from the face (mm).\n flip: Reverse the normal (draw from inside the body).\n\nReturns:\n SimpleWorkplane: a frame in world coordinates (``origin``, ``normal``, ``x_dir``, ``y_dir``, ``plane``) that is\n also a context manager: ``with wp:`` makes it the current coordinate system, ``make_sketch_rsketch(plane=wp)``\n draws on it. It is a value: enter it again in later code, pickle it (the notebook cell cache does).\n\nRaises:\n SimpleCADError: The face is not planar, both ``x_edge`` and ``x_dir`` were given, or the x direction is normal\n to the face.\n\nUsage:\n ``top = scad.ql.faces().where(scad.ql.tag(\"base.face.top\")).exactly(1).resolve(base)[0]``;\n ``wp = scad.make_workplane_from_face_rworkplane(top, offset=2)``.\n", "make_workplane_from_points_rworkplane": "Create a workplane through three points: origin at ``a``, x towards ``b``, normal = (b - a) x (c - a).\n\nRecorded: the node takes the inputs as ordered references, so a model-JSON replay builds the frame again from the\ngeometry as it is then, and a sketch on the workplane (``plane=wp``) or an operation inside ``with wp:`` follows it.\n\nArgs:\n a: A Vertex of the model or a point (current coordinate system): the origin.\n b: A Vertex or a point: x points from ``a`` towards it.\n c: A Vertex or a point on the positive-y side of the plane.\n\nReturns:\n SimpleWorkplane: a frame in world coordinates (``origin``, ``normal``, ``x_dir``, ``y_dir``, ``plane``) that is\n also a context manager: ``with wp:`` makes it the current coordinate system, ``make_sketch_rsketch(plane=wp)``\n draws on it. It is a value: enter it again in later code, pickle it (the notebook cell cache does).\n\nRaises:\n SimpleCADError: The three points lie on one line or two coincide.\n\nUsage:\n ``scad.make_workplane_from_points_rworkplane(v0, v1, v2)`` with three picked vertices; points given as\n numbers are recorded as numbers.\n", diff --git a/src/simplecadapi/_glue/modules.json b/src/simplecadapi/_glue/modules.json index 146f630a..181c19f0 100644 --- a/src/simplecadapi/_glue/modules.json +++ b/src/simplecadapi/_glue/modules.json @@ -189,7 +189,6 @@ "cut_rsolid": "simplecadapi.operators.boolean", "cut_rsolid_list": "simplecadapi.operators.boolean", "measure_part_mass_rmassreport": "simplecadapi.operators.product", -"inspect_body_overlaps_rlist": "simplecadapi.operators.product", "cut_shapes": "simplecadapi.kernel.ocp_booleans", "diagnose_blend_failure": "simplecadapi.operators._diagnostics", "diagnose_boolean_failure": "simplecadapi.operators._diagnostics", diff --git a/src/simplecadapi/_glue/session.py b/src/simplecadapi/_glue/session.py index 7b0fa38b..68072723 100644 --- a/src/simplecadapi/_glue/session.py +++ b/src/simplecadapi/_glue/session.py @@ -54,8 +54,7 @@ def lineage_of(value: Any) -> Optional[Tuple[str, str]]: def _children(item: Any) -> Iterable[Any]: kind = type(item).__name__ if kind == "Part": - for body in item.bodies: # one Solid, or every body of a multi-body part - yield body.solid + yield item.body yield from item.connectors if item.material is not None: yield item.material @@ -90,8 +89,7 @@ def walk_values(value: Any, *, deep: bool, seen: Optional[Set[int]] = None) -> I for item in value: yield from walk_values(item, deep=deep, seen=seen) elif deep: - # a result wrapped in the caller's own dataclass (the product values have their own children: a multi-body Part's - # `body` field refuses to name one body) + # a result wrapped in the caller's own dataclass (the product values have their own children, listed in _children) if dataclasses.is_dataclass(value) and not isinstance(value, type) and type(value).__name__ not in _PRODUCT_KINDS: for field in dataclasses.fields(value): if not field.name.startswith("_"): diff --git a/src/simplecadapi/_glue/values.py b/src/simplecadapi/_glue/values.py index 010d6f95..8b027c36 100644 --- a/src/simplecadapi/_glue/values.py +++ b/src/simplecadapi/_glue/values.py @@ -95,21 +95,18 @@ def _token_for(key: str, item: Any) -> str: def _plain_body(part: Any) -> bool: - """A one-body Part whose body is just its Solid: no name, colour or opacity, and the default id.""" + """A Part whose body is just its Solid: no name, colour or opacity, and the default id.""" - if part.multi_body: - return False body = part.bodies[0] return body.body_id == "body" and body.name is None and body.color is None and body.opacity is None -# Constructor arguments that are not read back by the attribute of the same name. A Part holds one ``body`` or -# several ``bodies``, and is rebuilt from exactly one of them (``Part.body`` raises for a multi-body Part). A one-body -# Part whose body carries a name or an appearance is rebuilt from its ``Body`` so they survive. +# Constructor arguments that are not read back by the attribute of the same name. A Part holds exactly one ``body`` +# (a Solid); a body that carries a name or an appearance is rebuilt from its ``Body`` (which ``Part(body=...)`` also +# takes) so they survive. _ARGUMENTS: Dict[str, Dict[str, Any]] = { "Part": { - "body": lambda value: value.body if _plain_body(value) else None, - "bodies": lambda value: None if _plain_body(value) else value.bodies, + "body": lambda value: value.body if _plain_body(value) else value.bodies[0], }, } diff --git a/src/simplecadapi/artifacts/assembly_io.py b/src/simplecadapi/artifacts/assembly_io.py index a9ad01ff..74dfae09 100644 --- a/src/simplecadapi/artifacts/assembly_io.py +++ b/src/simplecadapi/artifacts/assembly_io.py @@ -44,7 +44,7 @@ take_validated_part_body, with_definition_appearance, ) -from .part_io import encode_part_definition, load_part_definition, read_part_bodies +from .part_io import encode_part_definition, load_part_definition, read_part_body from .references import ConnectorInterface, PartRef from .topology_snapshot import ( restore_topology_snapshot, @@ -760,15 +760,13 @@ def materialize(node: PartDefinition | AssemblyDefinition) -> Part | Assembly: body = take_validated_part_body(node) if body is None: validate_artifact_blobs(node.to_dict(), node.blobs) - body = read_part_bodies(node) + body = read_part_body(node) mark_part_definition_validated(node) - body = with_definition_appearance(node, body) # body colours, opacity and names, when any - multi = isinstance(body, tuple) # the Body values of a multi-body part (or of a one-body part with an appearance) + body = with_definition_appearance(node, body) # the body's colour, opacity and name, when it has any value: Part | Assembly = Part( part_id=node.definition_id, name=node.metadata.get("name"), - body=None if multi else body, - bodies=body if multi else None, + body=body, material=_material_from_definition(node), connectors=tuple(_runtime_connector(item) for item in node.connectors), ) diff --git a/src/simplecadapi/artifacts/canonical.py b/src/simplecadapi/artifacts/canonical.py index 1562ad51..0b9d2269 100644 --- a/src/simplecadapi/artifacts/canonical.py +++ b/src/simplecadapi/artifacts/canonical.py @@ -14,8 +14,6 @@ ARTIFACT_SCHEMA_VERSION = "2.0" PART_DEFINITION_PROFILE = "simplecad-part-definition-2" -# A multi-body part (solid_cache.bodies): a reader that knows only profile 2 refuses it instead of misreading it. -MULTI_BODY_PART_DEFINITION_PROFILE = "simplecad-part-definition-3" ASSEMBLY_DEFINITION_PROFILE = "simplecad-assembly-definition-2" SOLID_EVALUATOR_PROFILE = "ocp-evaluated-solid-1" TOPOLOGY_SNAPSHOT_PROFILE = "ocp-stable-topology-4" @@ -198,7 +196,6 @@ def content_hash(value: Mapping[str, Any], *, omit: tuple[str, ...] = ("content_ "ArtifactLimits", "ArtifactValidationError", "DEFAULT_ARTIFACT_LIMITS", - "MULTI_BODY_PART_DEFINITION_PROFILE", "PART_DEFINITION_PROFILE", "SOLID_EVALUATOR_PROFILE", "TOPOLOGY_SNAPSHOT_PROFILE", diff --git a/src/simplecadapi/artifacts/geometry_interface.py b/src/simplecadapi/artifacts/geometry_interface.py index e232658c..0470ddd2 100644 --- a/src/simplecadapi/artifacts/geometry_interface.py +++ b/src/simplecadapi/artifacts/geometry_interface.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from collections import Counter, OrderedDict from typing import Any, Dict, List, Tuple @@ -31,7 +32,23 @@ def _enum_suffix(value: Any, prefix: str) -> str: def _q(value: float, tolerance: float) -> int: - return int(round(float(value) / tolerance)) + """``value`` in units of ``tolerance``, rounded to an integer, the same integer for values that differ by noise. + + A plain ``round(value / tolerance)`` splits a value that sits on a boundary, ``(k + 0.5) * tolerance`` (a design value + such as -3.34690815 mm does): the value computed one way is a unit in the last place under the boundary, the same + value after a BREP round trip is one over it, and the two quantize to neighbouring integers (issue #87: a body's + fingerprint changed and restoring its snapshot raised ``geometry_mismatch``). So the ratio is first snapped to a + binary grid that is far coarser than the noise (about 2**-46 of the ratio, at least 2**-24) and far finer than a + quantum, and a half-integer is exactly representable on it; every value within noise of a boundary snaps onto it and + then rounds, half to even, to one integer. A value farther from a boundary than the grid keeps the integer it had. + """ + scaled = float(value) / tolerance + if not math.isfinite(scaled): + return int(round(scaled)) + grid = math.ldexp(1.0, max(math.frexp(scaled)[1] - 46, -24)) + if grid < 0.5: # beyond 2**46 quanta the noise is itself half a quantum: there is nothing to snap + scaled = round(scaled / grid) * grid + return int(round(scaled)) def _qvec(values: Any, tolerance: float) -> tuple[int, ...]: diff --git a/src/simplecadapi/artifacts/part_definition.py b/src/simplecadapi/artifacts/part_definition.py index 6b8437a2..2c16584d 100644 --- a/src/simplecadapi/artifacts/part_definition.py +++ b/src/simplecadapi/artifacts/part_definition.py @@ -1,17 +1,10 @@ -"""Persistent PartDefinition contract: one evaluated Solid, or several named bodies. - -A one-body part is what every PartDefinition was: ``solid_cache.body_ref`` is the BRep of its Solid and -``topology_snapshot_ref`` the semantic state of that Solid. A multi-body part also lists ``solid_cache.bodies``: -one :class:`BodyRef` per body (its own BRep and topology snapshot, so each body keeps its tags), in body order. Its -``body_ref`` is then a BRep Compound of every body (what a geometry-only reader such as STEP needs, and what a reader -that expects exactly one Solid refuses), and ``topology_snapshot_ref`` repeats the first body's snapshot. -A multi-body definition has profile ``simplecad-part-definition-3`` (``contracts/part-definition-3.schema.json``), so -a reader that knows only profile 2 refuses it; a one-body definition keeps profile 2 and its bytes. - -``appearance`` (optional, both profiles) holds the display appearance of the bodies that have one -(``set_body_appearance_rpart``): ``{"bodies": [{"body_id", "color", "opacity"}]}``, plus ``name`` on the row of a -one-body part (a multi-body part's body names live in ``solid_cache.bodies``). A definition whose bodies have no -appearance writes no ``appearance`` key, so its bytes and content hash are what they always were. +"""Persistent PartDefinition contract: one evaluated Solid. + +``solid_cache.body_ref`` is the BRep of the Part's Solid and ``topology_snapshot_ref`` the semantic state of that Solid. + +``appearance`` (optional) holds the display appearance of the body when it has one (``set_body_appearance_rpart``): +``{"bodies": [{"body_id", "color", "opacity", "name"}]}``, one row at most. A definition whose body has no appearance +writes no ``appearance`` key, so its bytes and content hash are what they always were. """ from __future__ import annotations @@ -22,7 +15,6 @@ from .canonical import ( ARTIFACT_SCHEMA_VERSION, - MULTI_BODY_PART_DEFINITION_PROFILE, PART_DEFINITION_PROFILE, ArtifactValidationError, canonical_bytes, @@ -41,40 +33,6 @@ ) -@dataclass(frozen=True, slots=True) -class BodyRef: - """One body of a multi-body PartDefinition: its id, display name, BRep and topology snapshot.""" - - body_id: str - name: str | None - body_ref: BlobRef - topology_snapshot_ref: BlobRef - - def __post_init__(self) -> None: - object.__setattr__(self, "body_id", validate_logical_id(self.body_id, "/solid_cache/bodies/body_id")) - if self.name is not None and (not isinstance(self.name, str) or not self.name): - raise ArtifactValidationError("name_invalid", "/solid_cache/bodies/name", "expected a name or null") - if not isinstance(self.body_ref, BlobRef) or not isinstance(self.topology_snapshot_ref, BlobRef): - raise ArtifactValidationError("reference_invalid", "/solid_cache/bodies", "expected BlobRef values") - - def to_dict(self) -> dict[str, Any]: - return { - "body_id": self.body_id, - "name": self.name, - "body_ref": self.body_ref.to_dict(), - "topology_snapshot_ref": self.topology_snapshot_ref.to_dict(), - } - - @classmethod - def from_dict(cls, data: Mapping[str, Any], path: str) -> "BodyRef": - return cls( - body_id=str(data["body_id"]), - name=data.get("name"), - body_ref=BlobRef.from_dict(data["body_ref"], f"{path}/body_ref"), - topology_snapshot_ref=BlobRef.from_dict(data["topology_snapshot_ref"], f"{path}/topology_snapshot_ref"), - ) - - def _color(value: Any, path: str) -> tuple[float, float, float] | None: if value is None: return None @@ -89,8 +47,8 @@ def _color(value: Any, path: str) -> tuple[float, float, float] | None: @dataclass(frozen=True, slots=True) class BodyAppearance: - """The display appearance of one body of a PartDefinition: colour (RGB in [0, 1]), opacity and, for the body of - a one-body part, its display name. ``None`` fields are unset (the material colour, opaque, the body id).""" + """The display appearance of the body of a PartDefinition: colour (RGB in [0, 1]), opacity and display name. + ``None`` fields are unset (the material colour, opaque, the body id).""" body_id: str color: tuple[float, float, float] | None = None @@ -108,13 +66,12 @@ def __post_init__(self) -> None: raise ArtifactValidationError("name_invalid", "/appearance/bodies/name", "expected a name or null") @classmethod - def of_body(cls, body: Any, *, with_name: bool) -> "BodyAppearance | None": + def of_body(cls, body: Any) -> "BodyAppearance | None": """The appearance of a runtime ``Body``, or ``None`` when it has nothing to record.""" - name = body.name if with_name else None - if body.color is None and body.opacity is None and name is None: + if body.color is None and body.opacity is None and body.name is None: return None - return cls(body_id=body.body_id, color=body.color, opacity=body.opacity, name=name) + return cls(body_id=body.body_id, color=body.color, opacity=body.opacity, name=body.name) def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = { @@ -149,8 +106,7 @@ class PartDefinition: metadata: Mapping[str, Any] = field(default_factory=dict) content_hash: str = "" blobs: Mapping[str, bytes] = field(default_factory=dict, compare=False, repr=False) - bodies: tuple[BodyRef, ...] = () # empty for a one-body part; else every body, in order (at least two) - appearance: tuple[BodyAppearance, ...] = () # the bodies that have a display appearance (usually none) + appearance: tuple[BodyAppearance, ...] = () # the body's display appearance, when it has one (usually none) _validated_manifest: bytes | None = field( default=None, init=False, @@ -214,39 +170,12 @@ def __post_init__(self) -> None: "connector_invalid", "/connectors", "connector IDs must be unique" ) object.__setattr__(self, "connectors", connectors) - bodies = tuple(self.bodies) - if bodies: - if len(bodies) < 2 or not all(isinstance(item, BodyRef) for item in bodies): - raise ArtifactValidationError( - "bodies_invalid", "/solid_cache/bodies", "expected two or more BodyRef values" - ) - body_ids = [item.body_id for item in bodies] - if len(set(body_ids)) != len(body_ids): - raise ArtifactValidationError("bodies_invalid", "/solid_cache/bodies", "body IDs must be unique") - if connectors: - raise ArtifactValidationError( - "bodies_invalid", "/connectors", "connectors on a multi-body part are not supported yet" - ) - object.__setattr__(self, "bodies", bodies) appearance = tuple(self.appearance) if not all(isinstance(item, BodyAppearance) for item in appearance): raise ArtifactValidationError("appearance_invalid", "/appearance/bodies", "expected BodyAppearance values") - known = [item.body_id for item in bodies] if bodies else None - seen = [item.body_id for item in appearance] - if len(set(seen)) != len(seen): - raise ArtifactValidationError("appearance_invalid", "/appearance/bodies", "body IDs must be unique") - for item in appearance: - if known is not None and item.body_id not in known: - raise ArtifactValidationError("appearance_invalid", "/appearance/bodies", f"no body {item.body_id!r}") - if known is None and len(appearance) > 1: - raise ArtifactValidationError("appearance_invalid", "/appearance/bodies", "a one-body part has one body") - if known is not None and item.name is not None: - raise ArtifactValidationError( - "appearance_invalid", "/appearance/bodies/name", "a multi-body part names its bodies in solid_cache.bodies" - ) + if len(appearance) > 1: + raise ArtifactValidationError("appearance_invalid", "/appearance/bodies", "a part has one body") object.__setattr__(self, "appearance", appearance) - # a multi-body part is profile 3, so a profile-2 reader refuses it (one-body parts keep profile 2 and their bytes) - object.__setattr__(self, "profile", MULTI_BODY_PART_DEFINITION_PROFILE if bodies else PART_DEFINITION_PROFILE) if self.material_ref is not None and not isinstance( self.material_ref, MaterialRef ): @@ -343,8 +272,6 @@ def _manifest(self, *, content_hash_value: str) -> dict[str, Any]: "solid_cache": { "evaluator_profile": "ocp-evaluated-solid-1", "body_ref": self.solid_cache_ref.to_dict(), - # a one-body part writes the manifest it always wrote (same bytes, same content hash) - **({"bodies": [item.to_dict() for item in self.bodies]} if self.bodies else {}), }, "topology_snapshot_ref": self.topology_snapshot_ref.to_dict(), "connectors": [item.to_dict() for item in self.connectors], @@ -408,10 +335,6 @@ def from_dict( metadata=dict(data["metadata"]), content_hash=str(data["content_hash"]), blobs=blobs or {}, - bodies=tuple( - BodyRef.from_dict(item, f"/solid_cache/bodies/{index}") - for index, item in enumerate(cache.get("bodies") or ()) - ), appearance=tuple( BodyAppearance.from_dict(item) for item in ((data.get("appearance") or {}).get("bodies") or ()) ), @@ -451,23 +374,16 @@ def validated_part_feature_graph(definition: PartDefinition) -> Any: def with_definition_appearance(definition: PartDefinition, body: Any) -> Any: - """The bodies of *definition* with their appearance: *body* is what ``read_part_bodies`` gives (a Solid, or the - ``Body`` values of a multi-body part). Without an appearance it is returned as is; a one-body part with one - becomes a one-element tuple of ``Body`` (``Part(bodies=...)`` takes it).""" + """The body of *definition* with its appearance: *body* is the Solid ``read_part_body`` gives. Without an + appearance it is returned as it is; with one it becomes a ``Body`` (which ``Part(body=...)`` takes) carrying the + id, display name, colour and opacity.""" if not definition.appearance: return body from simplecadapi._cpp import Body - rows = {item.body_id: item for item in definition.appearance} - if not isinstance(body, tuple): - row = definition.appearance[0] - return (Body(row.body_id, body, row.name, row.color, row.opacity),) - out = [] - for item in body: - row = rows.get(item.body_id) - out.append(item if row is None else Body(item.body_id, item.solid, item.name, row.color, row.opacity)) - return tuple(out) + row = definition.appearance[0] + return Body(row.body_id, body, row.name, row.color, row.opacity) -__all__ = ["BodyAppearance", "BodyRef", "PartDefinition", "with_definition_appearance"] +__all__ = ["BodyAppearance", "PartDefinition", "with_definition_appearance"] diff --git a/src/simplecadapi/artifacts/part_io.py b/src/simplecadapi/artifacts/part_io.py index 8b098dbe..907494d5 100644 --- a/src/simplecadapi/artifacts/part_io.py +++ b/src/simplecadapi/artifacts/part_io.py @@ -64,9 +64,6 @@ def load_part_definition( material_ref = manifest["material_ref"] if material_ref is not None: expected_paths.add(str(material_ref["path"])) - for body in manifest["solid_cache"].get("bodies") or (): # a multi-body part: each body's BRep and snapshot - expected_paths.add(str(body["body_ref"]["path"])) - expected_paths.add(str(body["topology_snapshot_ref"]["path"])) expected_members = { _PART_DEFINITION_MANIFEST, *(_BLOB_PREFIX + path for path in expected_paths), @@ -93,7 +90,7 @@ def load_part_definition( "/definition/feature_graph_ref", "feature graph owner differs from PartDefinition identity", ) - body = read_part_bodies(definition) + body = read_part_body(definition) mark_part_definition_validated( definition, body=body, @@ -102,33 +99,13 @@ def load_part_definition( return definition -def read_part_bodies(definition: PartDefinition) -> Any: - """The Solid of a one-body definition, or the ``Body`` values of a multi-body one, with their semantic state. +def read_part_body(definition: PartDefinition) -> Any: + """The Solid of a definition, with its semantic state.""" - A multi-body definition's ``body_ref`` must be a Compound of exactly as many solids as it lists bodies. - """ - - if not definition.bodies: - body = read_brep_solid(definition.blobs[definition.solid_cache_ref.path]) - restore_topology_snapshot(body, definition.blobs[definition.topology_snapshot_ref.path]) - validate_connector_entity_bindings(body, definition.connectors) - return body - from simplecadapi._cpp import Body - - compound = read_brep_shape(definition.blobs[definition.solid_cache_ref.path]) - count = len(compound.solids()) if hasattr(compound, "solids") else 1 - if count != len(definition.bodies): - raise ArtifactValidationError( - "solid_cardinality_invalid", - "/solid_cache/body_ref", - f"BRep holds {count} solids, the definition lists {len(definition.bodies)} bodies", - ) - bodies = [] - for item in definition.bodies: - solid = read_brep_solid(definition.blobs[item.body_ref.path]) - restore_topology_snapshot(solid, definition.blobs[item.topology_snapshot_ref.path]) - bodies.append(Body(item.body_id, solid, item.name)) - return tuple(bodies) + body = read_brep_solid(definition.blobs[definition.solid_cache_ref.path]) + restore_topology_snapshot(body, definition.blobs[definition.topology_snapshot_ref.path]) + validate_connector_entity_bindings(body, definition.connectors) + return body def export_part_definition( diff --git a/src/simplecadapi/artifacts/validation.py b/src/simplecadapi/artifacts/validation.py index 7081110f..52e29d80 100644 --- a/src/simplecadapi/artifacts/validation.py +++ b/src/simplecadapi/artifacts/validation.py @@ -25,15 +25,13 @@ "part_definition": "part-definition-2.schema.json", "assembly_definition": "assembly-definition-2.schema.json", } -# Part definitions of a multi-body part carry profile 3 and are validated by their own schema. -_PROFILE_SCHEMA_FILES = {"simplecad-part-definition-3": "part-definition-3.schema.json"} @lru_cache(maxsize=4) -def _schema(kind: str, profile: str | None = None) -> Mapping[str, Any]: +def _schema(kind: str) -> Mapping[str, Any]: if kind not in _ARTIFACT_KINDS: raise ArtifactValidationError("artifact_kind_invalid", "/artifact_kind", "unsupported artifact kind") - name = _PROFILE_SCHEMA_FILES.get(profile or "", _SCHEMA_FILES[kind]) if kind == "part_definition" else _SCHEMA_FILES[kind] + name = _SCHEMA_FILES[kind] resource = files("simplecadapi").joinpath("contracts", name) schema = json.loads(resource.read_text(encoding="utf-8")) Draft202012Validator.check_schema(schema) @@ -47,7 +45,7 @@ def _pointer(parts: Iterable[Any]) -> str: def _schema_validate(manifest: Mapping[str, Any], kind: str) -> None: errors = sorted( - Draft202012Validator(_schema(kind, manifest.get("profile") if isinstance(manifest.get("profile"), str) else None)).iter_errors(manifest), + Draft202012Validator(_schema(kind)).iter_errors(manifest), key=lambda item: (list(item.absolute_path), item.message), ) if errors: @@ -139,9 +137,6 @@ def _blob_refs(manifest: Mapping[str, Any]) -> list[tuple[str, Mapping[str, Any] material = manifest["material_ref"] if material is not None: refs.append(("/material_ref", material)) - for index, body in enumerate(manifest["solid_cache"].get("bodies") or ()): # a multi-body part - refs.append((f"/solid_cache/bodies/{index}/body_ref", body["body_ref"])) - refs.append((f"/solid_cache/bodies/{index}/topology_snapshot_ref", body["topology_snapshot_ref"])) return refs return [("/feature_graph_ref", manifest["feature_graph_ref"])] diff --git a/src/simplecadapi/build/definition.py b/src/simplecadapi/build/definition.py index f2bb7f6b..33ffea7d 100644 --- a/src/simplecadapi/build/definition.py +++ b/src/simplecadapi/build/definition.py @@ -39,7 +39,7 @@ encode_feature_graph_artifact, ) from ..artifacts.geometry_interface import geometry_interface_fingerprint -from ..artifacts.part_definition import BodyAppearance, BodyRef, PartDefinition, mark_part_definition_validated +from ..artifacts.part_definition import BodyAppearance, PartDefinition, mark_part_definition_validated from ..artifacts.references import ( BlobRef, ConnectorInterface, @@ -72,7 +72,6 @@ suspend_graph_recording, ) from ..product.assembly import _AUTHORED_PLACEMENTS_RUNTIME_KEY, _component_occurrence_placements -from ..product.bodies import part_shape, warn_body_overlaps from ..product.placement import canonical_frame, placement_ticks from simplecadapi._glue.session import GraphSession, record_operation_if_active from ..recording.graph import attach_graph_node, attach_semantic_graph_node @@ -168,43 +167,9 @@ def _part_definition( feature_payload = encode_feature_graph_artifact(feature_graph) feature_path = _feature_graph_path(feature_graph) - body_blobs: dict[str, bytes] = {} - body_refs: tuple[BodyRef, ...] = () - if part.multi_body: - warn_body_overlaps(part, stacklevel=4) # allowed, but said once per build - # every body keeps its own BRep and snapshot (its tags); the part's BRep is a Compound of all of them - rows = [] - for item in part.bodies: - brep, brep_path = _brep_blob(item.solid) - snapshot, snapshot_path = _snapshot_blob(item.solid) - body_blobs[brep_path], body_blobs[snapshot_path] = brep, snapshot - rows.append( - BodyRef( - body_id=item.body_id, - name=item.name, - body_ref=_blob_ref(brep_path, brep, "application/vnd.opencascade.brep"), - topology_snapshot_ref=_blob_ref(snapshot_path, snapshot, "application/json"), - ) - ) - body_refs = tuple(rows) - body_payload, body_path = _brep_blob(part_shape(part)) - topology_payload, topology_path = _snapshot_blob(part.bodies[0].solid) - geometry_hash = content_hash( - { - "bodies": [ - { - "body_id": item.body_id, - "geometry": geometry_interface_fingerprint(item.solid, tolerance_profile=tolerance_profile), - } - for item in part.bodies - ] - }, - omit=(), - ) - else: - body_payload, body_path = _brep_blob(part.body) - topology_payload, topology_path = _snapshot_blob(part.body) - geometry_hash = geometry_interface_fingerprint(part.body, tolerance_profile=tolerance_profile) + body_payload, body_path = _brep_blob(part.body) + topology_payload, topology_path = _snapshot_blob(part.body) + geometry_hash = geometry_interface_fingerprint(part.body, tolerance_profile=tolerance_profile) material_ref, material_blobs = _material_ref(part.material) connectors = tuple( sorted( @@ -222,7 +187,6 @@ def _part_definition( feature_path: feature_payload, body_path: body_payload, topology_path: topology_payload, - **body_blobs, **material_blobs, } definition = PartDefinition( @@ -247,19 +211,17 @@ def _part_definition( interface_hashes=interface_hashes, metadata={"name": part.name}, blobs=blobs, - bodies=body_refs, appearance=tuple( row for item in part.bodies - if (row := BodyAppearance.of_body(item, with_name=not part.multi_body)) is not None + if (row := BodyAppearance.of_body(item)) is not None ), ) validate_artifact_blobs(definition.to_dict(), definition.blobs) - if not part.multi_body: # a multi-body part has no connectors yet - validate_connector_entity_bindings(part.body, definition.connectors) + validate_connector_entity_bindings(part.body, definition.connectors) mark_part_definition_validated( definition, - body=part.bodies if part.multi_body else part.body, + body=part.body, feature_graph=feature_graph, ) return definition @@ -293,7 +255,7 @@ def _attach_feature_graph_result( raise ArtifactValidationError( "result_invalid", "/result_node_ids/0", "result node does not exist" ) - for item in part.bodies: # one Solid, or every body of a multi-body part + for item in part.bodies: # the Part's one body attach_graph_node(item.solid, node, graph_id=session.graph.graph_id) attach_semantic_graph_node(part, node, graph_id=session.graph.graph_id) session._captured_values.append(part) diff --git a/src/simplecadapi/contracts/part-definition-3.schema.json b/src/simplecadapi/contracts/part-definition-3.schema.json deleted file mode 100644 index 956d72dc..00000000 --- a/src/simplecadapi/contracts/part-definition-3.schema.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://simplecad.dev/schemas/part-definition-3.schema.json", - "title": "SimpleCAD PartDefinition 2.0, profile 3 (multi-body parts)", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "artifact_kind", "definition_kind", "definition_id", "revision", "content_hash", "units", "tolerance_profile", "profile", "generator", "definition", "solid_cache", "topology_snapshot_ref", "connectors", "material_ref", "file_inputs", "interface_hashes", "metadata"], - "properties": { - "schema_version": {"const": "2.0"}, - "artifact_kind": {"const": "part_definition"}, - "definition_kind": {"const": "single_solid"}, - "definition_id": {"$ref": "#/$defs/id"}, - "revision": {"$ref": "#/$defs/revision"}, - "content_hash": {"$ref": "#/$defs/hash"}, - "units": {"const": "mm"}, - "tolerance_profile": {"type": "string", "minLength": 1, "maxLength": 128}, - "profile": {"const": "simplecad-part-definition-3"}, - "generator": {"$ref": "#/$defs/generator"}, - "definition": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "feature_graph_ref"], - "properties": { - "kind": {"const": "feature_graph"}, - "feature_graph_ref": {"$ref": "#/$defs/blobRef"} - } - }, - "solid_cache": { - "type": "object", - "additionalProperties": false, - "required": ["evaluator_profile", "body_ref", "bodies"], - "properties": { - "evaluator_profile": {"const": "ocp-evaluated-solid-1"}, - "body_ref": {"$ref": "#/$defs/blobRef"}, - "bodies": { - "type": "array", - "minItems": 2, - "maxItems": 10000, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["body_id", "name", "body_ref", "topology_snapshot_ref"], - "properties": { - "body_id": {"type": "string", "minLength": 1, "maxLength": 128}, - "name": {"type": ["string", "null"]}, - "body_ref": {"$ref": "#/$defs/blobRef"}, - "topology_snapshot_ref": {"$ref": "#/$defs/blobRef"} - } - } - } - } - }, - "topology_snapshot_ref": {"$ref": "#/$defs/blobRef"}, - "connectors": {"type": "array", "maxItems": 100000, "items": {"$ref": "#/$defs/connector"}}, - "material_ref": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/materialRef"}]}, - "file_inputs": {"type": "array", "maxItems": 1024, "items": {"$ref": "#/$defs/fileInput"}}, - "interface_hashes": {"$ref": "#/$defs/interfaceHashes"}, - "metadata": {"type": "object"}, - "appearance": { - "type": "object", - "additionalProperties": false, - "required": ["bodies"], - "properties": { - "bodies": { - "type": "array", - "minItems": 1, - "maxItems": 10000, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["body_id", "color", "opacity"], - "properties": { - "body_id": {"type": "string", "minLength": 1, "maxLength": 128}, - "color": {"oneOf": [{"type": "null"}, {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number", "minimum": 0, "maximum": 1}}]}, - "opacity": {"oneOf": [{"type": "null"}, {"type": "number", "minimum": 0, "maximum": 1}]}, - "name": {"type": "string", "minLength": 1} - } - } - } - } - } - }, - "$defs": { - "id": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, - "revision": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$"}, - "hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "path": {"type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]*$"}, - "vec3": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, - "frame": { - "type": "object", - "additionalProperties": false, - "required": ["origin", "x_axis", "y_axis", "z_axis"], - "properties": {"origin": {"$ref": "#/$defs/vec3"}, "x_axis": {"$ref": "#/$defs/vec3"}, "y_axis": {"$ref": "#/$defs/vec3"}, "z_axis": {"$ref": "#/$defs/vec3"}} - }, - "generator": { - "type": "object", - "additionalProperties": false, - "required": ["simplecadapi_version", "ocp_version", "python_abi", "platform_tag", "semantic_registry_version"], - "properties": { - "simplecadapi_version": {"$ref": "#/$defs/revision"}, - "ocp_version": {"$ref": "#/$defs/revision"}, - "python_abi": {"$ref": "#/$defs/revision"}, - "platform_tag": {"$ref": "#/$defs/revision"}, - "semantic_registry_version": {"$ref": "#/$defs/revision"} - } - }, - "blobRef": { - "type": "object", - "additionalProperties": false, - "required": ["path", "sha256", "byte_length", "media_type"], - "properties": { - "path": {"$ref": "#/$defs/path"}, - "sha256": {"$ref": "#/$defs/hash"}, - "byte_length": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, - "media_type": {"type": "string", "minLength": 1, "maxLength": 128} - } - }, - "fileInput": { - "type": "object", - "additionalProperties": false, - "required": ["path", "byte_length", "sha256"], - "properties": {"path": {"$ref": "#/$defs/path"}, "byte_length": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, "sha256": {"$ref": "#/$defs/hash"}} - }, - "materialRef": { - "type": "object", - "additionalProperties": false, - "required": ["material_id", "path", "revision", "sha256", "byte_length"], - "properties": {"material_id": {"$ref": "#/$defs/id"}, "path": {"$ref": "#/$defs/path"}, "revision": {"$ref": "#/$defs/revision"}, "sha256": {"$ref": "#/$defs/hash"}, "byte_length": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}} - }, - "connector": { - "type": "object", - "additionalProperties": false, - "required": ["connector_id", "name", "anchor_kind", "local_frame", "binding", "interface_hash", "binding_hash"], - "properties": { - "connector_id": {"$ref": "#/$defs/id"}, - "name": {"type": ["string", "null"], "minLength": 1}, - "anchor_kind": {"enum": ["geometry", "placement"]}, - "local_frame": {"$ref": "#/$defs/frame"}, - "binding": {"type": ["object", "null"]}, - "interface_hash": {"$ref": "#/$defs/hash"}, - "binding_hash": {"$ref": "#/$defs/hash"} - } - }, - "hashMap": {"type": "object", "propertyNames": {"$ref": "#/$defs/id"}, "additionalProperties": {"$ref": "#/$defs/hash"}}, - "interfaceHashes": { - "type": "object", - "additionalProperties": false, - "required": ["geometry", "connectors", "bindings", "material"], - "properties": {"geometry": {"$ref": "#/$defs/hash"}, "connectors": {"$ref": "#/$defs/hashMap"}, "bindings": {"$ref": "#/$defs/hashMap"}, "material": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/hash"}]}} - } - } -} diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index f0bb3e34..f7c3c977 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -453,6 +453,40 @@ def _opener() -> Any: return urllib.request.build_opener(urllib.request.ProxyHandler({})) +def _pid_alive(pid: int) -> bool: + """Probe a pid without signalling it. + + ``os.kill(pid, 0)`` is a POSIX liveness probe: on Windows ``os.kill`` + maps every signal except the two CTRL events to ``TerminateProcess``, + so probing the editor's pid that way kills the editor. + """ + import os + + if os.name == "nt": + import ctypes + + process_query_limited_information = 0x1000 + still_active = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + return code.value == still_active + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass # a live process we cannot signal + return True + + def _find_editor(start: Path) -> tuple[Path, str] | None: """The running editor whose workspace holds *start*: ``(workspace, base url)``. @@ -479,16 +513,11 @@ def _find_editor(start: Path) -> tuple[Path, str] | None: unreachable.append(f"{record}: unreadable ({error})") continue pid = body.get("pid") - if isinstance(pid, int): - try: - os.kill(pid, 0) - except ProcessLookupError: - unreachable.append( - f"stale record {record} (pid {pid} is gone); delete it or restart the editor" - ) - continue - except OSError: - pass # a live process we cannot signal + if isinstance(pid, int) and not _pid_alive(pid): + unreachable.append( + f"stale record {record} (pid {pid} is gone); delete it or restart the editor" + ) + continue try: with opener.open(f"{url}/api/health", timeout=1.5): pass diff --git a/src/simplecadapi/editor/gui_features.py b/src/simplecadapi/editor/gui_features.py index 3cb41cc3..bff2d9e4 100644 --- a/src/simplecadapi/editor/gui_features.py +++ b/src/simplecadapi/editor/gui_features.py @@ -167,9 +167,10 @@ def snapshot(self) -> dict[str, Any]: "normal_vec": sketch_doc.plane_normal(plane), } ) - if product is None and open_bodies and self._implicit_part(): - # no cell builds the Part: the runtime makes it from the open bodies (several are the bodies of one Part) - product = {"cell": None, "body": open_bodies[-1], "bodies": list(open_bodies), "implicit": True} + if product is None and len(open_bodies) == 1 and self._implicit_part(): + # no cell builds the Part: the runtime makes it from the one open body. Several open solids have no product + # yet (a Part holds exactly one solid): they are shown side by side and the author picks (use_as_product). + product = {"cell": None, "body": open_bodies[0], "implicit": True} return { "features": features, "bodies": [{"name": name, "produces": bodies[name]} for name in order], @@ -370,6 +371,12 @@ def use_as_product(self, request: Mapping[str, Any]) -> dict[str, Any]: 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"] and self._implicit_part() and name in snap["open"]: + # several solids are open and no cell makes the Part: the author's choice is the Part (a Part holds one solid) + made = self._product_cell(snap, name) + steps = [self._create(made["after"], f"{made['variable']} = scad.make_part_rpart(part_id=scad.notebook_id(), body={name})", run=True)] + self._record(steps) + return self._result(steps[0]["id"], steps, name) if not snap["product"]: raise GuiError("this notebook has no product cell that builds a Part from a body") if not snap["product"]["cell"]: diff --git a/src/simplecadapi/exporter/_surface_mesh.py b/src/simplecadapi/exporter/_surface_mesh.py index 1eaaede3..501cdc6d 100644 --- a/src/simplecadapi/exporter/_surface_mesh.py +++ b/src/simplecadapi/exporter/_surface_mesh.py @@ -10,7 +10,7 @@ from ..artifacts.assembly_io import materialize_definition from simplecadapi._cpp import Assembly, make_compound_from_assembly_rcompound, Part from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -42,7 +42,7 @@ def triangle_count(self) -> int: def _product_solids(value: Part | Assembly) -> tuple[Any, ...]: if isinstance(value, Part): - return tuple(body.solid for body in value.bodies) # every body of a multi-body part + return tuple(body.solid for body in value.bodies) # the Part's one body projection = make_compound_from_assembly_rcompound(assembly=value) solids = tuple(projection.solids()) if not solids: @@ -122,7 +122,7 @@ def tessellate_solid( def tessellate_product_surface( - data: ProductPackageInput, + data: ProductInput, *, linear_deflection: float, angular_deflection_degrees: float, diff --git a/src/simplecadapi/exporter/mjcf.py b/src/simplecadapi/exporter/mjcf.py index be6cd0f9..e8aa7f21 100644 --- a/src/simplecadapi/exporter/mjcf.py +++ b/src/simplecadapi/exporter/mjcf.py @@ -18,10 +18,9 @@ from ..artifacts.canonical import parse_canonical_json from ..artifacts.part_definition import PartDefinition from simplecadapi._cpp import Assembly, Part -from ..product.bodies import part_shape from ..product.placement import Placement, relative_placement from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) from ._surface_mesh import tessellate_solid @@ -340,7 +339,7 @@ def _part_materials(units: tuple[Any, ...]) -> dict[str, tuple[Part, float, bool def export_product_package_to_mjcf( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, @@ -678,7 +677,7 @@ def support_edge( mesh_path = mesh_root / f"{mesh_name}.obj" _write_obj( mesh_path, - part_shape(part), + part.body, linear_deflection=float(linear_deflection), angular_deflection_degrees=float(angular_deflection_degrees), ) diff --git a/src/simplecadapi/exporter/obj.py b/src/simplecadapi/exporter/obj.py index 07f77fcc..b917c7a4 100644 --- a/src/simplecadapi/exporter/obj.py +++ b/src/simplecadapi/exporter/obj.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import tessellate_product_surface @@ -26,7 +26,7 @@ class ProductOBJExportReport: def export_product_package_to_obj( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/exporter/step.py b/src/simplecadapi/exporter/step.py index 231891f0..b421ad56 100644 --- a/src/simplecadapi/exporter/step.py +++ b/src/simplecadapi/exporter/step.py @@ -12,9 +12,8 @@ from ..artifacts import materialize_definition from simplecadapi._cpp import Assembly, Part -from ..product.bodies import part_shape from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -74,8 +73,7 @@ def visit(value: Part | Assembly) -> str: "id": definition_id, "kind": kind, "name": value.name or value.part_id, - # a multi-body part is one STEP product whose shape holds every body - "shape": part_shape(value), + "shape": value.body, "metadata": { "record_kind": "definition", "definition_id": value.part_id, @@ -97,23 +95,9 @@ def visit(value: Part | Assembly) -> str: } if material.color is not None: entry["color"] = tuple(float(c) for c in material.color) - if not value.multi_body: - own = value.bodies[0].color # a body colour overrides the material colour - if own is not None: - entry["color"] = tuple(float(c) for c in own) - else: - # each body with a name or a colour of its own is a named / coloured solid of the product's shape - shape = entry["shape"] - solids = shape.solids() - entry["bodies"] = [ - { - "shape": solid, - "name": body.name, - "color": None if body.color is None else tuple(float(c) for c in body.color), - } - for body, solid in zip(value.bodies, solids) - if body.name is not None or body.color is not None - ] + own = value.bodies[0].color # a body colour overrides the material colour + if own is not None: + entry["color"] = tuple(float(c) for c in own) out[key] = entry return definition_id entry = { @@ -168,10 +152,11 @@ def visit(value: Part | Assembly) -> str: def export_product_package_to_step( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, ) -> ProductSTEPExportReport: - """Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP.""" + """Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of + a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene.""" import simplecadapi._cpp as native diff --git a/src/simplecadapi/exporter/stl.py b/src/simplecadapi/exporter/stl.py index ae8fab83..f1bcca9b 100644 --- a/src/simplecadapi/exporter/stl.py +++ b/src/simplecadapi/exporter/stl.py @@ -8,7 +8,7 @@ import numpy as np -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import TessellatedSurfaceMesh, tessellate_product_surface @@ -51,7 +51,7 @@ def _write_binary_stl(path: Path, mesh: TessellatedSurfaceMesh) -> None: def export_product_package_to_stl( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/operators/__init__.py b/src/simplecadapi/operators/__init__.py index 54d807d0..7a13c5da 100644 --- a/src/simplecadapi/operators/__init__.py +++ b/src/simplecadapi/operators/__init__.py @@ -250,7 +250,7 @@ from . import transform # noqa: F401 from . import selection # noqa: F401 from . import product # noqa: F401 -from .product import inspect_body_overlaps_rlist, measure_part_mass_rmassreport # Python-defined (product/bodies.py) +from .product import measure_part_mass_rmassreport # Python-defined (product/bodies.py) from . import sketch # noqa: F401 __all__ = [ @@ -346,7 +346,6 @@ "insert_curve_knot_redge", "insert_surface_knot_rface", "inspect_assembly_constraints_rconstraintreport", - "inspect_body_overlaps_rlist", "inspect_curve_rcurvedata", "inspect_sketch_rsketchcrossings", "inspect_sketch_rsketchprofiles", diff --git a/src/simplecadapi/operators/product.py b/src/simplecadapi/operators/product.py index 36111443..c02e2494 100644 --- a/src/simplecadapi/operators/product.py +++ b/src/simplecadapi/operators/product.py @@ -43,7 +43,7 @@ solve_assembly_constraints_rassembly, unground_component_rassembly, ) -from ..product.bodies import inspect_body_overlaps_rlist, measure_part_mass_rmassreport # Python-defined +from ..product.bodies import measure_part_mass_rmassreport # Python-defined __all__ = [ "add_belt_constraint_rassembly", @@ -72,7 +72,6 @@ "make_vertex_connector_rconnector", "measure_constraint_residual_rconstraintresidual", "measure_part_mass_rmassreport", - "inspect_body_overlaps_rlist", "place_component_rassembly", "set_body_appearance_rpart", "set_public_connector_rassembly", diff --git a/src/simplecadapi/product/bodies.py b/src/simplecadapi/product/bodies.py index 6867f592..f9165f0f 100644 --- a/src/simplecadapi/product/bodies.py +++ b/src/simplecadapi/product/bodies.py @@ -1,18 +1,15 @@ -"""The bodies of a Part: its shape as one value, and the mass of each body. +"""The body of a Part: its display colour and the mass report. -A Part holds one Solid or several named bodies (see ``Part.bodies``). Geometry consumers that do not care which body a -face belongs to (STEP, STL, MJCF meshes) read :func:`part_shape`; consumers that do (the preview, the package, mass -properties) walk ``part.bodies``. +A Part holds exactly one Solid (``Part.body``). ``Part.bodies`` is that body as a one-element tuple: a ``Body`` carries +the display name, colour and opacity (``set_body_appearance_rpart``). """ from __future__ import annotations -import warnings -from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any -from simplecadapi._cpp import Compound, Part, Solid, make_part_rpart +from simplecadapi._cpp import Part # density unit -> factor to kg/mm^3 (the model is in millimetres) _DENSITY_TO_KG_MM3 = { @@ -53,19 +50,6 @@ def body_appearance_fields(body: Any) -> dict[str, Any]: return out -def part_shape(part: Part) -> Solid | Compound: - """The Solid of a one-body Part, or a Compound of every body of a multi-body Part (in body order). - - The Compound is a geometry view: it is a new shape root and does not carry the bodies' tags. - """ - - if not isinstance(part, Part): - raise TypeError("part must be a Part") - if not part.multi_body: - return part.body - return Compound([body.solid for body in part.bodies]) - - @dataclass(frozen=True) class BodyMass: """Volume, mass and centre of mass of one body (millimetres, kilograms).""" @@ -135,27 +119,26 @@ def measure_part_mass_rmassreport( density: float | None = None, density_unit: str | None = None, ) -> MassReport: - """Measure the volume, mass and centre of mass of every body of a Part, and of the Part as a whole. + """Measure the volume, mass and centre of mass of a Part. Args: - part: The Part to measure (one body or several). + part: The Part to measure. density: Density to use instead of the material's; ``None`` uses the Part's material, and without a material density every mass is ``None``. density_unit: Unit of ``density``: ``kg/m^3`` (default), ``g/cm^3``, ``kg/mm^3`` or ``g/mm^3``. Returns: - MassReport: ``bodies`` (one ``BodyMass`` per body, in body order: ``body_id``, - ``volume_mm3``, ``mass_kg``, ``centroid_mm``) and the totals of the part - (``volume_mm3``, ``mass_kg``, volume-weighted ``centroid_mm``). + MassReport: ``bodies`` (the Part's one ``BodyMass``: ``body_id``, ``volume_mm3``, + ``mass_kg``, ``centroid_mm``) and the totals of the part (``volume_mm3``, + ``mass_kg``, ``centroid_mm``). Raises: SimpleCADError: The value is not a Part, or the density or its unit is invalid. Usage: - ``scad.measure_part_mass_rmassreport(case).body("lid").mass_kg`` is the mass of - the lid body; ``.mass_kg`` the mass of the whole part. Every body has the - Part's material. + ``scad.measure_part_mass_rmassreport(bracket).mass_kg`` is the mass of the part. + The part's material gives the density unless ``density`` is passed. """ if not isinstance(part, Part): @@ -189,112 +172,8 @@ def measure_part_mass_rmassreport( ) -class BodyOverlapWarning(UserWarning): - """Two bodies of one Part share volume: mass counts it twice and STEP holds both copies.""" - - -@dataclass(frozen=True) -class BodyOverlap: - """Two bodies of a Part that share volume (mm^3).""" - - first: str - second: str - volume_mm3: float - - def to_dict(self) -> dict[str, Any]: - return {"first": self.first, "second": self.second, "volume_mm3": self.volume_mm3} - - -def _boxes_meet(a: tuple[float, ...], b: tuple[float, ...], tol: float) -> bool: - return all(a[axis] <= b[axis + 3] + tol and b[axis] <= a[axis + 3] + tol for axis in range(3)) - - -def inspect_body_overlaps_rlist(part: Part, *, min_volume_mm3: float = 1e-6) -> list[BodyOverlap]: - """List the pairs of bodies of a Part that share volume. - - Overlapping bodies are allowed (a Part does not fuse them), but mass counts the - shared volume once per body and STEP holds both copies. Bodies that only touch - (a face, an edge or a point in common) do not overlap. - - Args: - part: The Part to inspect; a one-body Part has no overlaps. - min_volume_mm3: Shared volume below which a pair is not reported. - - Returns: - list[BodyOverlap]: ``(first, second, volume_mm3)`` per overlapping pair, in body order. - - Raises: - SimpleCADError: The value is not a Part. - - Usage: - ``scad.inspect_body_overlaps_rlist(case)`` before export. Building the part's - definition (a notebook run, ``@part``, a package) warns with ``BodyOverlapWarning`` - for the same pairs. - """ - - import simplecadapi._cpp._native as native - - if not isinstance(part, Part): - raise TypeError("part must be a Part") - occt = native._occt - bodies = list(part.bodies) - boxes = [body.solid._bounds() for body in bodies] - out: list[BodyOverlap] = [] - for i in range(len(bodies)): - for j in range(i + 1, len(bodies)): - if not _boxes_meet(boxes[i], boxes[j], 1e-7): - continue - common = occt.BRepAlgoAPI_Common(bodies[i].solid.wrapped, bodies[j].solid.wrapped).Shape() - props = occt.GProp_GProps() - occt.BRepGProp.VolumeProperties_s(common, props) - volume = float(props.Mass()) - if volume > min_volume_mm3: - out.append(BodyOverlap(bodies[i].body_id, bodies[j].body_id, volume)) - return out - - -def warn_body_overlaps(part: Part, *, stacklevel: int = 2) -> list[BodyOverlap]: - """Warn (``BodyOverlapWarning``) once per overlapping pair of bodies of *part*; returns the pairs.""" - - if not part.multi_body: - return [] - overlaps = inspect_body_overlaps_rlist(part) - for item in overlaps: - warnings.warn( - f"part {part.part_id!r}: bodies {item.first!r} and {item.second!r} overlap by {item.volume_mm3:.6g} mm^3 " - "(mass counts it twice; union them, or cut one from the other, if they are one piece of material)", - BodyOverlapWarning, - stacklevel=stacklevel, - ) - return overlaps - - -def part_from_bodies(part_id: str, bodies: Mapping[str, Solid | Sequence[Solid]], *, name: str | None = None) -> Part: - """The Part of named solids, the way a notebook names them: one name and one Solid is a one-body Part (body id - ``"body"``); otherwise every name is a body, and the pieces of a split cut (a list) are ``_1 .. _n``. - - This is the rule of an implicit product (a ``kind = "part"`` notebook without a ``make_part_rpart`` cell: its open - bodies, by variable name) and of an editor product cell with several bodies. - """ - - items = dict(bodies) - if not items: - raise ValueError("a Part needs at least one body") - if len(items) == 1: - (value,) = items.values() - if isinstance(value, Solid) or (isinstance(value, (list, tuple)) and len(value) == 1): - return make_part_rpart(part_id=part_id, body=value, name=name) - return make_part_rpart(part_id=part_id, bodies=items, name=name) - - __all__ = [ "BodyMass", - "BodyOverlap", - "BodyOverlapWarning", "MassReport", - "inspect_body_overlaps_rlist", "measure_part_mass_rmassreport", - "part_from_bodies", - "part_shape", - "warn_body_overlaps", ] diff --git a/src/simplecadapi/product/packages.py b/src/simplecadapi/product/packages.py index 4ba0152f..90a19617 100644 --- a/src/simplecadapi/product/packages.py +++ b/src/simplecadapi/product/packages.py @@ -212,19 +212,9 @@ def _definition_refs(definition: Definition) -> list[Mapping[str, Any]]: f"definition blob path {path!r} outside its semantic domain {role.path_prefix!r}" ) refs.append(ref.to_dict()) - for body in getattr(definition, "bodies", ()) or (): # every body of a multi-body part has its own BRep and snapshot - refs.extend(_checked_body_refs((body.body_ref.to_dict(), body.topology_snapshot_ref.to_dict()))) return refs -def _checked_body_refs(refs: tuple[Mapping[str, Any], ...]) -> list[Mapping[str, Any]]: - for ref, prefix in zip(refs, ("body/", "topology/")): - path = str(ref["path"]) - if not path.startswith(prefix): - raise ProductPackageError(f"definition blob path {path!r} outside its semantic domain {prefix!r}") - return list(refs) - - def _definition_closure(root: Definition) -> dict[str, Definition]: definitions: dict[str, Definition] = {} active: set[str] = set() diff --git a/src/simplecadapi/recording/serializer.py b/src/simplecadapi/recording/serializer.py index b29e6adb..4898733f 100644 --- a/src/simplecadapi/recording/serializer.py +++ b/src/simplecadapi/recording/serializer.py @@ -31,7 +31,6 @@ from simplecadapi._cpp import ( AnyShape, Assembly, - Body, Compound, Connector, ConnectorRef, @@ -386,13 +385,9 @@ "status": "diagnostic", "reason": "Measures current constraint residuals without changing model state.", }, - "inspect_body_overlaps_rlist": { - "status": "diagnostic", - "reason": "Reports which bodies of a Part share volume without changing model state.", - }, "measure_part_mass_rmassreport": { "status": "diagnostic", - "reason": "Measures volume and mass of each body without changing model state.", + "reason": "Measures volume and mass of a Part without changing model state.", }, "inspect_assembly_constraints_rconstraintreport": { "status": "diagnostic", @@ -3449,23 +3444,13 @@ def _store_semantic_outputs(node, result: Any) -> None: ctx.require_params(node.node_id, op_name, params, ("part_id",)) body_outputs = _feature_input_shapes(ctx, graph, outputs, node, params) rows = params.get("bodies") - if body_outputs and isinstance(rows, list) and len(rows) > 1: - # a multi-body part: one recorded input per body, in the order of the rows - if len(body_outputs) != len(rows): - raise ValueError( - f"make_part_rpart node {node.node_id}: {len(rows)} bodies recorded, " - f"{len(body_outputs)} inputs resolved" - ) - result = ops.make_part_rpart( - str(params["part_id"]), - name=cast(Optional[str], params.get("name")), - bodies=[ - Body(str(row["body_id"]), cast(Solid, shape), row.get("name")) - for row, shape in zip(rows, body_outputs) - ], + if isinstance(rows, list) and len(rows) > 1: + # a model recorded while a Part could hold several solids (SDK 2.2.8 development builds) + raise ValueError( + f"make_part_rpart node {node.node_id}: it recorded {len(rows)} solids for one Part; " + "a Part holds exactly one solid. Make one Part per solid and put them in an Assembly" ) - _store_outputs(node, result) - elif body_outputs: + if body_outputs: result = ops.make_part_rpart( str(params["part_id"]), cast(Solid, body_outputs[0]), @@ -4305,7 +4290,7 @@ def _vector(value: Any) -> Any: if op_name == "make_cut_rsolid": ctx.require_params( - node.node_id, op_name, params, ("tool_count",) + node.node_id, op_name, params, ("tool_count", "clean") ) if len(node.inputs) < 2: if ctx.strict: @@ -4335,13 +4320,14 @@ def _vector(value: Any) -> Any: ) ), tracking_policy=str(params.get("tracking_policy", "full")), + clean=bool(params.get("clean")), ) _store_outputs(node, result) continue if op_name == "make_cut_rsolid_list": ctx.require_params( - node.node_id, op_name, params, ("tool_count", "input_refs") + node.node_id, op_name, params, ("tool_count", "input_refs", "clean") ) inputs = _ordered_input_shapes(ctx, graph, outputs, node, params) if len(inputs) < 2: @@ -4355,6 +4341,7 @@ def _vector(value: Any) -> Any: _param(ctx, node.node_id, op_name, params, "skip_non_intersecting", False) ), "tracking_policy": str(params.get("tracking_policy", "full")), + "clean": bool(params.get("clean")), } if params.get("piece_order") == "centroid": # a split recorded by cut_rsolid(on_split="pieces"): the pieces in centroid order @@ -4420,14 +4407,16 @@ def _vector(value: Any) -> Any: if op_name == "make_intersect_rsolid": ctx.require_params( - node.node_id, op_name, params, ("input_count",) + node.node_id, op_name, params, ("input_count", "clean") ) all_solids = [ cast(Solid, shape) for shape in _all_input_outputs(ctx, outputs, node) ] if len(all_solids) >= 2: - result = ops.intersect_rsolid(all_solids[0], all_solids[1:]) + result = ops.intersect_rsolid( + all_solids[0], all_solids[1:], clean=bool(params.get("clean")) + ) _store_outputs(node, result) elif ctx.strict: ctx.fail( diff --git a/src/simplecadapi/ref.py b/src/simplecadapi/ref.py index e6c9cc02..0a4f17cb 100644 --- a/src/simplecadapi/ref.py +++ b/src/simplecadapi/ref.py @@ -431,7 +431,7 @@ def face( ordinal: Which of the op's faces with that role (0 first). topo: For a face without a role: its position in the op's output (``"face_5"``); give a ``guard`` with it. occurrence: Which of the feature's *op* operations feeding the shape (0: the first). - slot: The output of that operation (a multi-body result has one per body). + slot: The output of that operation (an operation that makes several solids has one per solid). piece: The chosen piece once the face has been split (in the order the split error lists them). follow: ``"identity"``; ``"derived"`` also follows into what the face generated. guard: ``(geometry type, (x, y, z), area[, normal])`` of the face when it was picked. diff --git a/src/simplecadapi/runtime/deps.py b/src/simplecadapi/runtime/deps.py index fbf691e2..1a5a1c5d 100644 --- a/src/simplecadapi/runtime/deps.py +++ b/src/simplecadapi/runtime/deps.py @@ -99,7 +99,7 @@ def state_dir(config: NotebookConfig) -> Path: @contextmanager -def exclusive(config: NotebookConfig) -> Iterator[None]: +def exclusive(config: NotebookConfig, *, enabled: bool = True) -> Iterator[None]: """Hold the notebook's state directory against other processes. Worker processes may run the same notebook at once (two notebooks that @@ -107,9 +107,13 @@ def exclusive(config: NotebookConfig) -> Iterator[None]: lock is between processes only: a run of a notebook this process is already running (a ``use`` cycle, an editor next to a headless run) goes on without it, as it always did. Without ``fcntl`` (Windows) it is a - no-op. + no-op. With ``enabled=False`` (a run that keeps no state: nothing to + prune, nothing to protect) it is a no-op too, and creates nothing. """ + if not enabled: + yield + return directory = state_dir(config) key = str(directory) with _held_lock: diff --git a/src/simplecadapi/runtime/live.py b/src/simplecadapi/runtime/live.py index 5f58e9d1..527d53a3 100644 --- a/src/simplecadapi/runtime/live.py +++ b/src/simplecadapi/runtime/live.py @@ -29,12 +29,31 @@ from ..artifacts.assembly_io import attached_definition from ..product.packages import build_product_package, encode_product_package from .._cpp import Face, Shell -from ..scene.preview import PreviewScene, add_surfaces, compile_preview, encode_preview, entity_info, refine_preview +from ..scene.preview import ( + PreviewScene, + add_surfaces, + compile_open_bodies, + compile_preview, + encode_preview, + entity_info, + refine_preview, +) from ._marimo import safe_get_context from .cells import NotebookCells from .config import NotebookConfig, read_notebook_config from .executor import finish_kernel_cache -from .projection import NoProductYetError, Product, ProductNotFoundError, find_product, open_bodies, product_reads, project_product +from .projection import ( + NoProductYetError, + OpenBodies, + Product, + ProductNotFoundError, + SeveralOpenBodiesError, + find_product, + loose_solids, + open_bodies, + product_reads, + project_product, +) from .workspace import features_in_cell PACKAGE_MEDIA_TYPE = "application/vnd.simplecad.scadpkg" @@ -221,7 +240,7 @@ def preview_product( failed: Iterable[FailedCell] = (), skipped: Iterable[str] = (), lost_reads: AbstractSet[str] = frozenset(), -) -> tuple[Product, dict[str, Any] | None]: +) -> tuple[Product | OpenBodies, dict[str, Any] | None]: """The product to preview from *values* (the cells that are left, see :class:`LiveScope`), and whether it is partial. With no failed cell this is :func:`~.projection.find_product`. With one, the product is found the same way among the @@ -230,6 +249,9 @@ def preview_product( failing step. The second item says what is shown (``None`` when the product is whole): ``{"failed": [...], "skipped": [cell ids], "shown": [names]}``. A product no failed cell would have built on (an explicit product upstream of none of them) is whole. Raises :class:`~.projection.ProductNotFoundError` when nothing is left to show, naming the failed cell. + + A part notebook that ends with several open solids and no cell making the Part of one of them has no product yet (a + Part holds exactly one solid): what is shown is :class:`~.projection.OpenBodies`, the solids side by side. """ failed = tuple(failed) @@ -237,6 +259,9 @@ def preview_product( read = product_reads(cells, values) try: product = find_product(config, values, read) + except SeveralOpenBodiesError as error: + # the model is the solids themselves: a tool and its target, shown before a cell makes the Part of one of them + product = OpenBodies(config.id, loose_solids(values, error.names)) except ProductNotFoundError as error: if not failed: raise @@ -284,7 +309,12 @@ def _live_scene() -> PreviewScene: except NoProductYetError: # no solid yet (only sketches, curves, workplanes): the model is empty, which is no fault product, partial = None, None - scene = compile_preview(product) if product is not None else PreviewScene(parts={}, nodes=[], solids={}, definitions={}) + if product is None: + scene = PreviewScene(parts={}, nodes=[], solids={}, definitions={}) + elif isinstance(product, OpenBodies): + scene = compile_open_bodies(product.part_id, product.bodies) + else: + scene = compile_preview(product) # the surfaces no later cell builds on (a loft, a patch, a shell sewn so far) are shown with the model add_surfaces(scene, open_surfaces(scope.values, product_reads(scope.cells, scope.values))) last_timings["compile_ms"] = round((time.perf_counter() - started) * 1000, 1) diff --git a/src/simplecadapi/runtime/projection.py b/src/simplecadapi/runtime/projection.py index 16166a94..302c0cd8 100644 --- a/src/simplecadapi/runtime/projection.py +++ b/src/simplecadapi/runtime/projection.py @@ -26,6 +26,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Set as AbstractSet +from dataclasses import dataclass from ..artifacts.assembly_io import attached_definition from ..build.assembly_builder import declared_definitions @@ -39,9 +40,18 @@ import weakref from typing import Any -from simplecadapi._cpp import Assembly, Edge, find_registered_node, isolated_recording, Part, SimpleWorkplane, Solid, Wire +from simplecadapi._cpp import ( + Assembly, + Edge, + find_registered_node, + isolated_recording, + make_part_rpart, + Part, + SimpleWorkplane, + Solid, + Wire, +) from simplecadapi._glue.session import lineage_of -from ..product.bodies import part_from_bodies from simplecadapi._glue.session import GraphSession from ..recording.source_mapping import cell_sources from .cells import NotebookCells, bound_requirements @@ -54,6 +64,30 @@ class ProductNotFoundError(ValueError): """No top-level value, or more than one, is the notebook's product.""" +class SeveralOpenBodiesError(ProductNotFoundError): + """A ``kind = "part"`` notebook ends with several solids nothing is built from, and no cell makes the Part. + + A Part holds exactly one solid, so the runtime cannot choose for the author which of them is the part. ``names`` are + the variables of the open solids (cells in order; the names a single cell defines come in no fixed order); a preview + shows them side by side (:class:`OpenBodies`). + """ + + def __init__(self, message: str, names: tuple[str, ...]) -> None: + super().__init__(message) + self.names = names + + +@dataclass(frozen=True) +class OpenBodies: + """The solids a notebook has made and not yet used, shown side by side: what a preview draws while no cell makes a + Part of one of them (a tool next to the body it will cut). They are loose values, not a product: nothing places, + mates or exports them.""" + + part_id: str + bodies: Mapping[str, Solid] + """Solid by name: the variable, or ``_`` for the n-th piece of a split cut.""" + + class NoProductYetError(ProductNotFoundError): """The notebook makes no Part, no Assembly and no solid yet (a new notebook, or one with only sketches, curves and workplanes so far). There is no product to export, but nothing is wrong: a preview of it is empty.""" @@ -129,7 +163,7 @@ def find_product( f"{', '.join(names)}" ) if config.kind == "part" and not others: - implicit = _implicit_part(config, values, read) + implicit = _implicit_part(config, values, read) # SeveralOpenBodiesError when it cannot choose if implicit is not None: return implicit if not others: @@ -189,17 +223,45 @@ def _ancestors(node: Any) -> set[str]: return out +def loose_solids(values: Mapping[str, object], names: Iterable[str]) -> dict[str, Solid]: + """The solids behind *names* (variables of open bodies): a Solid by its name, the pieces of a split cut as + ``_1 .. _n``.""" + + out: dict[str, Solid] = {} + for name in names: + value = values[name] + if isinstance(value, Solid): + out[name] = value + elif len(value) == 1: # type: ignore[arg-type] + out[name] = value[0] # type: ignore[index] + else: + for index, piece in enumerate(value, start=1): # type: ignore[arg-type] + out[f"{name}_{index}"] = piece + return out + + def _implicit_part(config: NotebookConfig, values: Mapping[str, object], read: AbstractSet[str]) -> Part | None: - """The product of a ``kind = "part"`` notebook without a cell that builds it: its open bodies, as a Part with the notebook id. + """The product of a ``kind = "part"`` notebook without a cell that builds it: its one open body, as a Part with the + notebook id (body id ``"body"``). ``None`` when there is no body yet. - One open body is a one-body Part (body id ``"body"``); several are the bodies of one Part, named by their variables - (the pieces of a split cut ``_1..n``). ``None`` when there is no body yet. + A Part holds exactly one solid. Several open solids are several things the notebook has not chosen between: + :class:`SeveralOpenBodiesError` says which, and what to do. """ names = open_bodies(values, read) if not names: return None - part = part_from_bodies(config.id, {name: values[name] for name in names}) + solids = loose_solids(values, names) + if len(solids) > 1: + raise SeveralOpenBodiesError( + f"{config.path.name}: {len(solids)} solids are open at the end of the notebook ({', '.join(solids)}) and no " + "cell makes the Part from one of them. A Part holds exactly one solid: make it from the solid that is the " + "part (scad.make_part_rpart(part_id=scad.notebook_id(), body=...)); if they are separate parts, make a Part " + "of each and an Assembly of them", + tuple(names), + ) + (solid,) = solids.values() + part = make_part_rpart(part_id=config.id, body=solid) _IMPLICIT[id(part)] = part return part @@ -236,9 +298,9 @@ def project_product( with isolated_recording(), cell_sources(cells): if _IMPLICIT.get(id(product)) is product: # the implicit Part of a kind = "part" notebook was made outside any cell: make it here, recorded, so the - # definition has its one result node (the Part), whatever the number of bodies + # definition has its one result node (the Part) with session: - product = part_from_bodies(product.part_id, {body.body_id: body.solid for body in product.bodies}) + product = make_part_rpart(part_id=product.part_id, body=product.body) session.validate_graph_ownership(product) for node in session.graph.topological_order(): for expression in node.expressions: @@ -272,4 +334,13 @@ def project_product( ).value -__all__ = ["NoProductYetError", "Product", "ProductNotFoundError", "find_product", "project_product"] +__all__ = [ + "NoProductYetError", + "OpenBodies", + "Product", + "ProductNotFoundError", + "SeveralOpenBodiesError", + "find_product", + "loose_solids", + "project_product", +] diff --git a/src/simplecadapi/runtime/runner.py b/src/simplecadapi/runtime/runner.py index 5d17e614..b1007e13 100644 --- a/src/simplecadapi/runtime/runner.py +++ b/src/simplecadapi/runtime/runner.py @@ -170,7 +170,7 @@ def run() -> NotebookRun: # Errors still come through. "marimo" is the logger every marimo # module shares (lazy.py's record shows its file name, "lazy", which # is not the logger's name). - with suppress_warnings_logs("marimo"), exclusive(config): + with suppress_warnings_logs("marimo"), exclusive(config, enabled=cache): return _run(config, values, cache=cache, through=through, product=product) # marimo keeps its runtime context per thread, and a notebook run needs @@ -322,7 +322,10 @@ def _run( # Cells restored from the cache ran no ``use`` this time; the files they # depend on are among those the previous run recorded. recorded = frozenset(used) | (previous if restored else frozenset()) - write_dependencies(config, recorded) + if cache: + # The record belongs to the cache: its entries are keyed on the files it lists, so a run that keeps no entries must + # not move it (and a run without the cache must work where the source directory is read-only, issue #89). + write_dependencies(config, recorded) dependencies = module_files | recorded if cache and cell_cache is not None: # A cell that runs a child notebook depends on the record just written: diff --git a/src/simplecadapi/scene/preview.py b/src/simplecadapi/scene/preview.py index d22afc4f..53f6b68a 100644 --- a/src/simplecadapi/scene/preview.py +++ b/src/simplecadapi/scene/preview.py @@ -30,7 +30,7 @@ import threading from collections import OrderedDict from dataclasses import dataclass, field -from typing import Any, Collection +from typing import Any, Collection, Mapping import numpy as np @@ -101,7 +101,8 @@ class PreviewScene: definitions: dict[str, str] """The definition id each part key belongs to.""" bodies: dict[str, str] = field(default_factory=dict) - """The body id of each part key that draws one body of a multi-body part (one-body parts are not listed).""" + """The name of each part key that draws one open solid of the notebook (:func:`compile_open_bodies`); the parts of a + product are not listed.""" # -- the per-part cache -------------------------------------------------------------- @@ -272,51 +273,28 @@ def compile_preview( parts: dict[str, PartPreview] = {} solids: dict[str, Solid] = {} definitions: dict[str, str] = {} - bodies: dict[str, str] = {} nodes: list[PreviewNode] = [] def add(part: Part, path: tuple[str, ...], placement: Placement, name: str) -> None: - if not part.multi_body: - body = part.body - if not isinstance(body, Solid): - raise PreviewError(f"part {part.part_id!r} has no solid body") - preview = preview_solid(body, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) - parts[preview.key] = preview - solids.setdefault(preview.key, body) - definitions.setdefault(preview.key, part.part_id) - only = part.bodies[0] - nodes.append( - PreviewNode( - node_id="node/" + "/".join(path), - part_key=preview.key, - definition_id=part.part_id, - display_name=name, - placement=placement, - color=body_display_color(part, only), - opacity=only.opacity, - ) - ) - return - # A multi-body part: one node per body, all with the part's placement (bodies are never placed apart). The - # part key names the body as well, so two bodies of the same shape stay two entity namespaces. - for item in part.bodies: - preview = preview_solid(item.solid, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) - key = f"{preview.key}/body:{item.body_id}" - parts[key] = preview - solids.setdefault(key, item.solid) - definitions.setdefault(key, part.part_id) - bodies.setdefault(key, item.body_id) - nodes.append( - PreviewNode( - node_id="node/" + "/".join(path) + "/body:" + item.body_id, - part_key=key, - definition_id=part.part_id, - display_name=f"{name} / {item.name or item.body_id}", - placement=placement, - color=body_display_color(part, item), - opacity=item.opacity, - ) + body = part.body + if not isinstance(body, Solid): + raise PreviewError(f"part {part.part_id!r} has no solid body") + preview = preview_solid(body, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) + parts[preview.key] = preview + solids.setdefault(preview.key, body) + definitions.setdefault(preview.key, part.part_id) + only = part.bodies[0] + nodes.append( + PreviewNode( + node_id="node/" + "/".join(path), + part_key=preview.key, + definition_id=part.part_id, + display_name=name, + placement=placement, + color=body_display_color(part, only), + opacity=only.opacity, ) + ) def visit(item: Part | Assembly, path: tuple[str, ...], placement: Placement, name: str) -> None: if isinstance(item, Part): @@ -334,7 +312,48 @@ def visit(item: Part | Assembly, path: tuple[str, ...], placement: Placement, na visit(product, (root_id,), identity_placement(), root_id) if not nodes: raise PreviewError("the product has nothing to draw") - return PreviewScene(parts=parts, nodes=nodes, solids=solids, definitions=definitions, bodies=bodies) + return PreviewScene(parts=parts, nodes=nodes, solids=solids, definitions=definitions) + + +def compile_open_bodies( + part_id: str, + bodies: Mapping[str, Solid], + *, + linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE, + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE, +) -> PreviewScene: + """Preview solids that exist side by side and are not a product: the open solids of a notebook (a tool and the + body it will cut), by name. + + Each is a node ``node//body:`` in world position, with its own entity namespace + (``entity//body:/...``, so two solids of the same shape stay two) and the viewer's default colour. + They are loose values: nothing places, mates or exports them, and a Part holds exactly one solid. + """ + + parts: dict[str, PartPreview] = {} + solids: dict[str, Solid] = {} + definitions: dict[str, str] = {} + names: dict[str, str] = {} + nodes: list[PreviewNode] = [] + for name, solid in bodies.items(): + preview = preview_solid(solid, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance) + key = f"{preview.key}/body:{name}" + parts[key] = preview + solids[key] = solid + definitions[key] = part_id + names[key] = name + nodes.append( + PreviewNode( + node_id=f"node/{part_id}/body:{name}", + part_key=key, + definition_id=part_id, + display_name=name, + placement=identity_placement(), + ) + ) + if not nodes: + raise PreviewError("there is no solid to draw") + return PreviewScene(parts=parts, nodes=nodes, solids=solids, definitions=definitions, bodies=names) SURFACE_COLOR = (0.55, 0.78, 0.95) @@ -407,8 +426,8 @@ def array(values: np.ndarray, dtype: str) -> dict[str, int]: continue definition = scene.definitions[key] body_id = scene.bodies.get(key) - # entity///; a body of a multi-body part: entity//body:// (as in - # the package's scene, scene/product_scene.py) + # entity///; an open solid of a notebook (compile_open_bodies): + # entity//body:// prefix = f"entity/{definition}/" if body_id is None else f"entity/{definition}/body:{body_id}/" parts.append( { @@ -502,15 +521,15 @@ def decode_preview_header(data: bytes) -> dict[str, Any]: def entity_info(scene: PreviewScene, entity_id: str) -> dict[str, Any] | None: """The geometric properties of the entity *entity_id* of a part of *scene*. - ``entity///`` (``entity//body:/...`` for a body of a - multi-body part); returns what the package's entity + ``entity///`` (``entity//body:/...`` for an open solid of a + notebook); returns what the package's entity sidecar holds for it (``properties``, ``geometry``, ``tags``) except the ``source`` links into the feature graph, or ``None`` if there is no such entity. """ _, _, rest = entity_id.partition("entity/") definition, _, tail = rest.partition("/") body_id = "" - if tail.startswith("body:"): # a body of a multi-body part + if tail.startswith("body:"): # an open solid of a notebook segment, _, tail = tail.partition("/") body_id = segment[len("body:") :] kind, _, topo_id = tail.partition("/") diff --git a/src/simplecadapi/scene/prewarm.py b/src/simplecadapi/scene/prewarm.py index 63757d90..503877f2 100644 --- a/src/simplecadapi/scene/prewarm.py +++ b/src/simplecadapi/scene/prewarm.py @@ -36,7 +36,7 @@ def visit(item: Any) -> Iterator[Solid]: return seen.add(id(item)) if isinstance(item, Part): - for body in item.bodies: # every body of a multi-body part + for body in item.bodies: # the Part's one body if isinstance(body.solid, Solid): yield body.solid elif isinstance(item, Assembly): diff --git a/src/simplecadapi/scene/product_scene.py b/src/simplecadapi/scene/product_scene.py index f4c02f52..4962312b 100644 --- a/src/simplecadapi/scene/product_scene.py +++ b/src/simplecadapi/scene/product_scene.py @@ -319,8 +319,6 @@ def _part_geometry( str, ]: runtime = materialize_definition(definition) - if runtime.multi_body: - raise ProductSceneError(f"part {definition.definition_id!r} has several bodies: use _body_geometries") body = runtime.body if not isinstance(body, Solid): raise ProductSceneError(f"part {definition.definition_id!r} has no solid body") @@ -342,40 +340,6 @@ def _part_geometry( ) -def _body_geometries( - definition: PartDefinition, - *, - linear_tolerance: float, - angular_tolerance: float, -) -> list[tuple[str, str | None, tuple[list[dict[str, Any]], dict[str, Any], dict[str, bytes], str, str]]]: - """The geometry of every body of a multi-body part: ``(body_id, name, the _solid_geometry tuple)`` in body order. - - Each body has its own GLB and entity document; its entities are ``entity//body://``. - """ - - runtime = materialize_definition(definition) - mark_part_definition_validated(definition, body=runtime.bodies, feature_graph=_feature_graph(definition)) - refs = {item.body_id: item for item in definition.bodies} - out = [] - for body in runtime.bodies: - snapshot = parse_artifact_json(definition.blobs[refs[body.body_id].topology_snapshot_ref.path]) - out.append( - ( - body.body_id, - body.name, - _solid_geometry( - definition.definition_id, - f"{definition.definition_id}/body:{body.body_id}", - body.solid, - snapshot, - linear_tolerance=linear_tolerance, - angular_tolerance=angular_tolerance, - ), - ) - ) - return out - - def _solid_geometry( definition_id: str, namespace: str, @@ -602,20 +566,19 @@ def _node_id(path: tuple[str, ...]) -> str: return "node/" + "/".join(path) -def _appearance_properties(definition: PartDefinition, body_id: str | None) -> dict[str, Any]: - """``color`` / ``opacity`` of a body that has its own (``body_id`` None: the body of a one-body part), else ``{}``. +def _appearance_properties(definition: PartDefinition) -> dict[str, Any]: + """``color`` / ``opacity`` of the part's body when it has its own (``set_body_appearance_rpart``), else ``{}``. A viewer draws a node with ``properties.color`` in that colour; without one, the material's (``material_id``). """ for row in definition.appearance: - if body_id is None or row.body_id == body_id: - out: dict[str, Any] = {} - if row.color is not None: - out["color"] = list(row.color) - if row.opacity is not None: - out["opacity"] = row.opacity - return out + out: dict[str, Any] = {} + if row.color is not None: + out["color"] = list(row.color) + if row.opacity is not None: + out["opacity"] = row.opacity + return out return {} @@ -624,7 +587,6 @@ def _build_nodes( definitions: Mapping[str, Definition], geometry_ids: Mapping[str, str], entity_ids: Mapping[str, str], - body_assets: Mapping[str, list[tuple[str, str | None, str, str]]] = {}, ) -> tuple[ list[dict[str, Any]], dict[str, list[str]], @@ -667,31 +629,11 @@ def visit( "properties": { "content_hash": definition.content_hash, "revision": definition.revision, - # a one-body part's body colour / opacity (set_body_appearance_rpart), when it has one - **(_appearance_properties(definition, None) if is_part and not definition.bodies else {}), + # the part's body colour / opacity (set_body_appearance_rpart), when it has one + **(_appearance_properties(definition) if is_part else {}), }, } ) - part_node = nodes[-1] - for body_id, body_name, geometry_id, entity_id in body_assets.get(definition.definition_id, ()): - # a body of a multi-body part: a solid node under the part node, at the part's origin - nodes.append( - { - "node_id": f"{node_id}/body:{body_id}", - "parent_node_id": node_id, - "display_name": body_name or body_id, - "node_kind": "solid", - "definition_id": definition.definition_id, - "definition_kind": definition.definition_kind, - "component_id": None, - "instance_id": None, - "transform": identity_placement().to_dict(), - "geometry_asset_id": geometry_id, - "entity_asset_id": entity_id, - "material_id": part_node["material_id"], - "properties": {"body_id": body_id, **_appearance_properties(definition, body_id)}, - } - ) if isinstance(definition, AssemblyDefinition): for instance in definition.instances: child = definitions[instance.definition_id] @@ -1057,21 +999,7 @@ def compile_product_scene( for definition in definitions.values() if isinstance(definition, PartDefinition) ] - body_assets: dict[str, list[tuple[str, str | None, str, str]]] = {} for definition in sorted(part_definitions, key=lambda item: item.definition_id): - if definition.bodies: # a multi-body part: one GLB and entity document per body, drawn by child nodes - rows = [] - for body_id, body_name, (records, entity_record, part_blobs, geometry_id, entity_id) in _body_geometries( - definition, linear_tolerance=linear_tolerance, angular_tolerance=angular_tolerance - ): - for record in records: - geometry_assets_by_id.setdefault(record["asset_id"], record) - if all(item["asset_id"] != entity_record["asset_id"] for item in entity_assets): - entity_assets.append(entity_record) - blobs.update(part_blobs) - rows.append((body_id, body_name, geometry_id, entity_id)) - body_assets[definition.definition_id] = rows - continue geometry_records, entity_record, part_blobs, geometry_id, entity_id = ( _part_geometry( definition, @@ -1087,7 +1015,7 @@ def compile_product_scene( entity_ids[definition.definition_id] = entity_id nodes, occurrences, children = _build_nodes( - root, definitions, geometry_ids, entity_ids, body_assets + root, definitions, geometry_ids, entity_ids ) connectors, connector_index = _connectors( definitions, feature_graphs, occurrences diff --git a/src/simplecadapi/translator/package_units.py b/src/simplecadapi/translator/package_units.py index 445666f2..8cd131cc 100644 --- a/src/simplecadapi/translator/package_units.py +++ b/src/simplecadapi/translator/package_units.py @@ -11,6 +11,7 @@ from ..artifacts.part_definition import PartDefinition from ..product.packages import ( ProductPackage, + build_product_package, read_product_package, validate_product_package, ) @@ -18,6 +19,8 @@ Definition = PartDefinition | AssemblyDefinition ProductPackageInput = ProductPackage | bytes | bytearray | memoryview | str | Path +# What an exporter takes besides a package: the result of a build (``@scad.part`` / ``@scad.assemble``) or its definition. +ProductInput = ProductPackageInput | Definition | Any @dataclass(frozen=True, slots=True) @@ -70,15 +73,22 @@ def model_payload(self) -> dict[str, Any]: def read_product_package_translation_units( - data: ProductPackageInput, + data: ProductInput, ) -> tuple[ProductPackage, tuple[ProductPackageTranslationUnit, ...]]: - """Return a validated package and dependency-first definition units.""" + """Return a validated package and dependency-first definition units. + + *data* is a package, the bytes or path of one, or (issue #89) the result of a build or a definition: a script that + only wants a STEP file need not write a ``.scadpkg`` first. That package is built in memory without the scene + projection (tessellated meshes), which no exporter reads and which took most of the time of a default capture. + """ if isinstance(data, ProductPackage): validate_product_package(data) package = data - else: + elif isinstance(data, (bytes, bytearray, memoryview, str, Path)): package = read_product_package(data) + else: + package = build_product_package(data, include_scene=False) ordered: list[Definition] = [] visited: set[tuple[str, str]] = set() @@ -122,5 +132,6 @@ def visit(definition: Definition) -> None: __all__ = [ "ProductPackageInput", "ProductPackageTranslationUnit", + "ProductInput", "read_product_package_translation_units", ] diff --git a/test/test_body_appearance.py b/test/test_body_appearance.py index 093525b0..663b1497 100644 --- a/test/test_body_appearance.py +++ b/test/test_body_appearance.py @@ -1,8 +1,11 @@ -"""Body appearance: a display colour, opacity and name per body of a Part (``set_body_appearance_rpart``). +"""Body appearance: a display colour, opacity and name for the body of a Part (``set_body_appearance_rpart``). The material colour is the default; a body colour overrides it for display. The appearance reaches the live preview, the legacy scene compiler, the product package (part definition and package scene), STEP, the cell cache (pickle) -and graph replay. A Part whose bodies have no appearance is byte for byte what it was. +and graph replay. A Part whose body has no appearance is byte for byte what it was. + +A Part holds exactly one solid, so its body id is always ``"body"``; the display of several solids is the business of +several Parts (an Assembly) or of loose notebook values (see ``test_one_solid_part.py``). """ from __future__ import annotations @@ -29,66 +32,56 @@ BLUE = (0.1, 0.3, 0.8) -def _case() -> scad.Part: - return scad.make_part_rpart( - part_id="case", - bodies={ - "base": scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base"), - "lid": scad.make_box_rsolid(20.0, 20.0, 2.0, (40.0, 0.0, 0.0), tag_prefix="lid"), - }, - ) - - def _plate() -> scad.Part: return scad.make_part_rpart(part_id="plate", body=scad.make_box_rsolid(10.0, 10.0, 2.0)) class TestOperation(unittest.TestCase): - def test_sets_colour_opacity_and_name_of_one_body(self) -> None: - case = _case() - painted = scad.set_body_appearance_rpart(case, "lid", color=RED, opacity=0.5, name="Lid") - lid = painted.get_body("lid") - self.assertEqual(lid.color, RED) - self.assertEqual(lid.opacity, 0.5) - self.assertEqual(lid.name, "Lid") - self.assertIsNone(painted.get_body("base").color) - self.assertIsNone(case.get_body("lid").color) # values are immutable - self.assertEqual(painted.body_ids(), ("base", "lid")) + def test_sets_colour_opacity_and_name_of_the_body(self) -> None: + plate = _plate() + painted = scad.set_body_appearance_rpart(plate, "body", color=RED, opacity=0.5, name="Plate") + body = painted.get_body("body") + self.assertEqual(body.color, RED) + self.assertEqual(body.opacity, 0.5) + self.assertEqual(body.name, "Plate") + self.assertIsNone(plate.get_body("body").color) # values are immutable + self.assertEqual(painted.body_ids(), ("body",)) self.assertEqual( - painted.to_dict()["bodies"][1], {"body_id": "lid", "name": "Lid", "color": list(RED), "opacity": 0.5} + painted.to_dict()["bodies"], [{"body_id": "body", "name": "Plate", "color": list(RED), "opacity": 0.5}] ) def test_none_keeps_and_reset_clears(self) -> None: - painted = scad.set_body_appearance_rpart(_case(), "lid", color=RED, name="Lid") - again = scad.set_body_appearance_rpart(painted, "lid", opacity=0.25) - self.assertEqual((again.get_body("lid").color, again.get_body("lid").name), (RED, "Lid")) - cleared = scad.set_body_appearance_rpart(again, "lid", reset=True) - lid = cleared.get_body("lid") - self.assertEqual((lid.color, lid.opacity, lid.name), (None, None, None)) - self.assertEqual(cleared.to_dict()["bodies"][1], {"body_id": "lid", "name": None}) - - def test_one_body_part_uses_the_default_body(self) -> None: + painted = scad.set_body_appearance_rpart(_plate(), "body", color=RED, name="Plate") + again = scad.set_body_appearance_rpart(painted, "body", opacity=0.25) + self.assertEqual((again.bodies[0].color, again.bodies[0].name), (RED, "Plate")) + cleared = scad.set_body_appearance_rpart(again, "body", reset=True) + body = cleared.bodies[0] + self.assertEqual((body.color, body.opacity, body.name), (None, None, None)) + self.assertNotIn("bodies", cleared.to_dict()) # a plain body keeps the dict it always had + + def test_the_body_id_defaults_to_body(self) -> None: plate = scad.set_body_appearance_rpart(_plate(), color=BLUE) - self.assertFalse(plate.multi_body) self.assertEqual(plate.bodies[0].color, BLUE) self.assertEqual(plate.to_dict()["bodies"], [{"body_id": "body", "name": None, "color": list(BLUE)}]) - self.assertNotIn("bodies", _plate().to_dict()) # a plain one-body part keeps its dict + self.assertNotIn("bodies", _plate().to_dict()) # a plain part keeps its dict def test_material_is_kept_and_overridden_for_display_only(self) -> None: steel = scad.make_material_rmaterial("steel", color=(0.5, 0.5, 0.5), density=7.85, density_unit="g/cm^3") - case = scad.set_body_appearance_rpart(scad.assign_material_rpart(_case(), steel), "lid", color=RED) - self.assertEqual(case.material.material_id, "steel") - mass = scad.measure_part_mass_rmassreport(case) - self.assertAlmostEqual(mass.body("lid").mass_kg, 800.0 * 7.85e-6, places=12) - colors = {node.node_id: node.color for node in compile_preview(case).nodes} - self.assertEqual(colors, {"node/case/body:base": (0.5, 0.5, 0.5), "node/case/body:lid": RED}) + plate = scad.set_body_appearance_rpart(scad.assign_material_rpart(_plate(), steel), color=RED) + self.assertEqual(plate.material.material_id, "steel") + mass = scad.measure_part_mass_rmassreport(plate) + self.assertAlmostEqual(mass.mass_kg, 200.0 * 7.85e-6, places=12) # the density is the material's, not the colour's + colors = {node.node_id: node.color for node in compile_preview(plate).nodes} + self.assertEqual(colors, {"node/plate": RED}) + plain = scad.assign_material_rpart(_plate(), steel) + self.assertEqual({node.color for node in compile_preview(plain).nodes}, {(0.5, 0.5, 0.5)}) def test_errors(self) -> None: for kwargs in ({"color": (1.5, 0, 0)}, {"color": (1, 0)}, {"opacity": 2.0}, {"name": " "}): with self.subTest(kwargs=kwargs), self.assertRaises(scad.SimpleCADError): - scad.set_body_appearance_rpart(_case(), "lid", **kwargs) + scad.set_body_appearance_rpart(_plate(), "body", **kwargs) with self.assertRaisesRegex(scad.SimpleCADError, "hinge"): - scad.set_body_appearance_rpart(_case(), "hinge", color=RED) + scad.set_body_appearance_rpart(_plate(), "hinge", color=RED) with self.assertRaises(scad.SimpleCADError): scad.set_body_appearance_rpart("not a part", color=RED) with self.assertRaises(Exception): @@ -107,38 +100,31 @@ def test_catalog_types_every_parameter(self) -> None: class TestCacheAndReplay(unittest.TestCase): def test_pickle_and_deepcopy_keep_the_appearance(self) -> None: - case = scad.set_body_appearance_rpart(_case(), "lid", color=RED, opacity=0.5, name="Lid") - plate = scad.set_body_appearance_rpart(_plate(), color=BLUE, name="Plate") - for again in (pickle.loads(pickle.dumps(case)), copy.deepcopy(case)): - lid = again.get_body("lid") - self.assertEqual((lid.color, lid.opacity, lid.name), (RED, 0.5, "Lid")) - one = pickle.loads(pickle.dumps(plate)) - self.assertFalse(one.multi_body) - self.assertEqual((one.bodies[0].color, one.bodies[0].name), (BLUE, "Plate")) + plate = scad.set_body_appearance_rpart(_plate(), color=BLUE, opacity=0.5, name="Plate") + for again in (pickle.loads(pickle.dumps(plate)), copy.deepcopy(plate)): + body = again.bodies[0] + self.assertEqual((body.color, body.opacity, body.name), (BLUE, 0.5, "Plate")) plain = pickle.loads(pickle.dumps(_plate())) self.assertIsNone(plain.bodies[0].color) + self.assertEqual(plain.body_ids(), ("body",)) def test_recorded_and_replayed(self) -> None: with scad.GraphSession() as session: - case = scad.set_body_appearance_rpart(_case(), "lid", color=RED, opacity=0.5, name="Lid") - scad.set_body_appearance_rpart(case, "base", color=BLUE) + plate = scad.set_body_appearance_rpart(_plate(), color=RED, opacity=0.5, name="Plate") + scad.set_body_appearance_rpart(plate, color=BLUE) model = scad.export_model_json(session) self.assertIn("make_set_body_appearance_rpart", model) parts = [item for item in scad.replay_model_json(model) if isinstance(item, scad.Part)] - final = parts[-1] - self.assertEqual(final.get_body("base").color, BLUE) - lid = final.get_body("lid") - self.assertEqual((lid.color, lid.opacity, lid.name), (RED, 0.5, "Lid")) + body = parts[-1].bodies[0] + self.assertEqual((body.color, body.opacity, body.name), (BLUE, 0.5, "Plate")) class TestPreviewAndScene(unittest.TestCase): def test_preview_header_carries_colour_and_opacity(self) -> None: - case = scad.set_body_appearance_rpart(_case(), "lid", color=RED, opacity=0.5, name="Lid") - nodes = {n["node_id"]: n for n in decode_preview_header(encode_preview(compile_preview(case)))["nodes"]} - self.assertEqual(nodes["node/case/body:lid"]["color"], list(RED)) - self.assertEqual(nodes["node/case/body:lid"]["opacity"], 0.5) - self.assertEqual(nodes["node/case/body:lid"]["display_name"], "case / Lid") - self.assertNotIn("color", nodes["node/case/body:base"]) + plate = scad.set_body_appearance_rpart(_plate(), color=RED, opacity=0.5, name="Plate") + (node,) = decode_preview_header(encode_preview(compile_preview(plate)))["nodes"] + self.assertEqual(node["color"], list(RED)) + self.assertEqual(node["opacity"], 0.5) def test_a_plain_preview_header_is_unchanged(self) -> None: header = decode_preview_header(encode_preview(compile_preview(_plate()))) @@ -154,25 +140,6 @@ def test_legacy_scene_compiler_uses_the_body_colour(self) -> None: self.assertIn([*BLUE, 1], colors) -PAINTED = notebook_text( - cell( - """ - base = scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") - lid = scad.make_box_rsolid(20.0, 20.0, 2.0, (40.0, 0.0, 0.0), tag_prefix="lid") - """, - returns="base, lid", - ), - cell( - """ - case = scad.make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid}) - case = scad.set_body_appearance_rpart(case, "lid", color=(0.9, 0.2, 0.1), opacity=0.5, name="Lid") - """, - reads="base, lid", - returns="case", - ), - table='id = "case"', -) - PLATE = notebook_text( cell( """ @@ -191,32 +158,18 @@ def test_legacy_scene_compiler_uses_the_body_colour(self) -> None: class TestPackage(NotebookTestCase): - def test_multi_body_definition_round_trip(self) -> None: - path = self.write("case.py", PAINTED) + def test_definition_keeps_profile_2_and_round_trips(self) -> None: + path = self.write("plate.py", PLATE) first = run_notebook(path) second = run_notebook(path) self.assertEqual(self.statuses(second)[-1], "cached") - # (the content hash of a cached multi-body run differs from the first run on this branch already: - # test_multi_body_part's cache test, known WIP of the stable-refs merge; the appearance is the same) - self.assertEqual(first.definition.to_dict()["appearance"], second.definition.to_dict()["appearance"]) + self.assertEqual(first.definition.content_hash, second.definition.content_hash) manifest = second.definition.to_dict() - self.assertEqual(manifest["profile"], "simplecad-part-definition-3") - self.assertEqual(manifest["appearance"], {"bodies": [{"body_id": "lid", "color": list(RED), "opacity": 0.5}]}) - self.assertEqual([row["name"] for row in manifest["solid_cache"]["bodies"]], [None, "Lid"]) - validate_manifest(manifest, "part_definition") - part = materialize_definition(load_product_package(encode_product_package(build_product_package(second.definition)))) - lid = part.get_body("lid") - self.assertEqual((lid.color, lid.opacity, lid.name), (RED, 0.5, "Lid")) - self.assertIsNone(part.get_body("base").color) - - def test_one_body_definition_keeps_profile_2_and_round_trips(self) -> None: - definition = run_notebook(self.write("plate.py", PLATE)).definition - manifest = definition.to_dict() self.assertEqual(manifest["profile"], "simplecad-part-definition-2") self.assertEqual(manifest["appearance"], {"bodies": [{"body_id": "body", "color": list(BLUE), "opacity": None, "name": "Plate"}]}) + self.assertNotIn("bodies", manifest["solid_cache"]) validate_manifest(manifest, "part_definition") - part = materialize_definition(load_product_package(encode_product_package(build_product_package(definition)))) - self.assertFalse(part.multi_body) + part = materialize_definition(load_product_package(encode_product_package(build_product_package(second.definition)))) self.assertEqual((part.bodies[0].color, part.bodies[0].name), (BLUE, "Plate")) def test_a_plain_definition_has_no_appearance_key(self) -> None: @@ -227,26 +180,20 @@ def test_a_plain_definition_has_no_appearance_key(self) -> None: def test_package_scene_nodes_carry_the_colour(self) -> None: from simplecadapi.scene.product_scene import compile_product_scene - nodes = {n["node_id"]: n for n in compile_product_scene(run_notebook(self.write("case.py", PAINTED)).definition).manifest["nodes"]} - self.assertEqual(nodes["node/case/body:lid"]["properties"]["color"], list(RED)) - self.assertEqual(nodes["node/case/body:lid"]["properties"]["opacity"], 0.5) - self.assertNotIn("color", nodes["node/case/body:base"]["properties"]) plate = compile_product_scene(run_notebook(self.write("plate.py", PLATE)).definition).manifest["nodes"] self.assertEqual(plate[0]["properties"]["color"], list(BLUE)) - - def test_step_writes_the_body_colours_and_names(self) -> None: - for name, text, expected, label in (("case.py", PAINTED, RED, "Lid"), ("plate.py", PLATE, BLUE, None)): - data = encode_product_package(build_product_package(run_notebook(self.write(name, text)).definition)) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "out.step" - export_product_package_to_step(data, path) - step = path.read_text(encoding="utf-8", errors="replace") - rgb = [tuple(round(float(v), 3) for v in m) for m in re.findall(r"COLOUR_RGB\('[^']*',([\d.Ee+-]+),([\d.Ee+-]+),([\d.Ee+-]+)\)", step)] - # OCCT takes the colour as linear RGB (Quantity_TOC_RGB, as for a material colour) and STEP stores sRGB - srgb = tuple(round(c * 12.92 if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055, 3) for c in expected) - self.assertTrue(any(all(abs(a - b) < 2e-3 for a, b in zip(srgb, row)) for row in rgb), (name, rgb)) - if label: - self.assertIn(f"'{label}'", step) + self.assertEqual(len(plate), 1) # one node: a Part has no child solid nodes + + def test_step_writes_the_body_colour(self) -> None: + data = encode_product_package(build_product_package(run_notebook(self.write("plate.py", PLATE)).definition)) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "out.step" + export_product_package_to_step(data, path) + step = path.read_text(encoding="utf-8", errors="replace") + rgb = [tuple(round(float(v), 3) for v in m) for m in re.findall(r"COLOUR_RGB\('[^']*',([\d.Ee+-]+),([\d.Ee+-]+),([\d.Ee+-]+)\)", step)] + # OCCT takes the colour as linear RGB (Quantity_TOC_RGB, as for a material colour) and STEP stores sRGB + srgb = tuple(round(c * 12.92 if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055, 3) for c in BLUE) + self.assertTrue(any(all(abs(a - b) < 2e-3 for a, b in zip(srgb, row)) for row in rgb), rgb) if __name__ == "__main__": diff --git a/test/test_boolean_clean.py b/test/test_boolean_clean.py new file mode 100644 index 00000000..0c5844a0 --- /dev/null +++ b/test/test_boolean_clean.py @@ -0,0 +1,128 @@ +"""cut / intersect unify what they leave split, and a blend never hands back an invalid solid (issue #88). + +A cut through the side of a box with a cylinder splits the cylinder wall along the cylinder's parametric seam into two +faces, and the top edge of the notch into a long and a short arc. OCCT then fillets that top edge wrongly: it returns +an invalid solid with a volume that is too small (case A), or refuses and the diagnosis blames the radius (case B). +The same geometry fillets fine once the faces are unified, which is what union already did and cut did not. +""" + +from __future__ import annotations + +import unittest + +import simplecadapi as scad +from simplecadapi import ql +from simplecadapi._cpp.occt.BRepCheck import BRepCheck_Analyzer +from simplecadapi.errors import SimpleCADError +from simplecadapi.recording.graph import GraphSession + +# the issue's two placements of the cylinder; volumes are what CadQuery and build123d (which clean by default) give +CASE_A = (0.0, 17.5) +CASE_B = (-12.5, -17.5) +VOLUME_A = 29604.46 +VOLUME_B = 29811.31 + + +def notch(cx: float, cy: float, **options): + box = scad.make_box_rsolid(width=40, height=40, depth=20, bottom_face_center=(0, 0, 0)) + cylinder = scad.make_cylinder_rsolid(radius=10, height=40, bottom_face_center=(cx, cy, 10)) + return scad.cut_rsolid(box, cylinder, **options) + + +def top_edges(solid, z: float = 20.0): + return [ + edge + for edge in ql.edges().resolve(solid) + if abs(edge.get_start_vertex().get_coordinates()[2] - z) < 1e-6 + and abs(edge.get_end_vertex().get_coordinates()[2] - z) < 1e-6 + ] + + +def is_valid(solid) -> bool: + return bool(BRepCheck_Analyzer(solid.wrapped).IsValid()) + + +def face_count(solid) -> int: + return len(ql.faces().resolve(solid)) + + +class TestCutCleans(unittest.TestCase): + def test_cut_unifies_the_wall_the_seam_split(self): + self.assertEqual(face_count(notch(*CASE_A)), 8) + self.assertEqual(face_count(notch(*CASE_A, clean=False)), 9) + + def test_the_cleaned_cut_keeps_its_volume(self): + self.assertAlmostEqual(notch(*CASE_A).get_volume(), notch(*CASE_A, clean=False).get_volume(), places=3) + + def test_cut_list_and_intersect_clean_by_default_too(self): + box = scad.make_box_rsolid(width=40, height=40, depth=20, bottom_face_center=(0, 0, 0)) + cylinder = scad.make_cylinder_rsolid(radius=10, height=40, bottom_face_center=(0, 17.5, 10)) + (piece,) = scad.cut_rsolid_list(box, cylinder) + self.assertEqual(face_count(piece), 8) + (raw,) = scad.cut_rsolid_list(box, cylinder, clean=False) + self.assertEqual(face_count(raw), 9) + # the part of the box inside the cylinder: a cylinder wall cut along its seam is two faces unless unified + inside = scad.intersect_rsolid(box, cylinder) + raw_inside = scad.intersect_rsolid(box, cylinder, clean=False) + self.assertLess(face_count(inside), face_count(raw_inside)) + self.assertAlmostEqual(inside.get_volume(), raw_inside.get_volume(), places=3) + + +class TestFilletAfterCut(unittest.TestCase): + def test_case_a_fillets_to_a_valid_solid_with_the_right_volume(self): + cut = notch(*CASE_A) + filleted = scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertTrue(is_valid(filleted)) + self.assertAlmostEqual(filleted.get_volume(), VOLUME_A, delta=0.05) + + def test_case_b_fillets_with_the_radius_the_diagnosis_called_too_large(self): + cut = notch(*CASE_B) + filleted = scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertTrue(is_valid(filleted)) + self.assertAlmostEqual(filleted.get_volume(), VOLUME_B, delta=0.05) + + def test_an_unclean_cut_no_longer_yields_an_invalid_fillet(self): + """Case A without the clean-up: OCCT's fillet is invalid. It used to be returned; now it is an error.""" + cut = notch(*CASE_A, clean=False) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertIn("not a valid solid", str(raised.exception)) + + def test_an_unclean_cut_names_the_split_faces_not_the_radius(self): + """Case B without the clean-up: the kernel refuses, and the diagnosis says why instead of 'reduce the radius'.""" + cut = notch(*CASE_B, clean=False) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + message = str(raised.exception) + self.assertIn("split on a surface", message) + self.assertIn("clean=True", message) + self.assertNotIn("Reduce the radius", message) + + def test_a_radius_that_really_is_too_large_is_still_reported_as_such(self): + cut = notch(*CASE_A) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=50.0) + self.assertNotIn("split on a surface", str(raised.exception)) + + +class TestRecordedClean(unittest.TestCase): + """``clean`` is a recorded parameter like ``skip_non_intersecting``; replay reads it, there is no default for a + record that lacks it.""" + + def record(self, **options): + with GraphSession() as session: + notch(*CASE_A, **options) + return session + + def test_the_flag_is_recorded(self): + self.assertTrue(self.record().graph.leaf_nodes()[0].params["clean"]) + self.assertFalse(self.record(clean=False).graph.leaf_nodes()[0].params["clean"]) + + def test_replay_reproduces_the_recorded_result(self): + for options, faces in (({}, 8), ({"clean": False}, 9)): + (replayed,) = scad.replay_graph(self.record(**options).graph) # the leaf: the cut + self.assertEqual(face_count(replayed), faces, options) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_cell_cache_store.py b/test/test_cell_cache_store.py index c4caef0c..a75d71de 100644 --- a/test/test_cell_cache_store.py +++ b/test/test_cell_cache_store.py @@ -163,7 +163,7 @@ def test_a_cell_returning_a_selector_is_cached(self) -> None: reads="pieces", returns="moved, kind", ), - cell("part = scad.make_part_rpart(part_id='split', body=moved)", reads="moved", returns="part"), + cell("part = scad.make_part_rpart(part_id='split', body=moved[0])", reads="moved", returns="part"), # a Part holds one solid: one piece table='id = "split"', ) @@ -179,4 +179,5 @@ def test_a_restored_split_feeds_the_next_cell_as_pieces(self) -> None: second = run_notebook(nb) self.assertEqual(self.statuses(second), ["ran", "cached", "ran", "ran"]) self.assertEqual(second.values["kind"], "Pieces") - self.assertEqual(len(second.definition.bodies), len(first.definition.bodies)) + self.assertEqual(second.product.body_ids(), first.product.body_ids()) # one piece is the Part (a Part holds one solid) + self.assertAlmostEqual(second.product.body.get_center().z, first.product.body.get_center().z + 1.0, places=6) diff --git a/test/test_editor.py b/test/test_editor.py index cbde287a..c2d0b038 100644 --- a/test/test_editor.py +++ b/test/test_editor.py @@ -865,10 +865,10 @@ def test_classes_list_their_constructor_without_private_fields(self) -> None: part = self.scad["Part"] self.assertEqual(part["k"], "c") names = [p["n"] for p in part["p"]] - # a Part takes one ``body`` or several ``bodies`` (multi-body parts), so only part_id is required - self.assertEqual([p["n"] for p in part["p"] if p["r"]], ["part_id"]) + # a Part holds exactly one solid: ``part_id`` and ``body`` are required, there is no ``bodies`` + self.assertEqual([p["n"] for p in part["p"] if p["r"]], ["part_id", "body"]) self.assertIn("body", names) - self.assertIn("bodies", names) + self.assertNotIn("bodies", names) self.assertFalse([n for n in names if n.startswith("_")]) def test_namespaces_and_submodules(self) -> None: diff --git a/test/test_editor_features.py b/test/test_editor_features.py index 3f7da784..b8a39a93 100644 --- a/test/test_editor_features.py +++ b/test/test_editor_features.py @@ -315,7 +315,7 @@ def test_a_blank_notebook_gets_its_product_with_the_first_feature(tmp_path): snap = service.snapshot() assert [f["slug"] for f in snap["features"]] == ["base"] # the header says kind = "part": the runtime makes the Part from the open body, no product cell is written - assert snap["product"] == {"cell": None, "body": "base", "bodies": ["base"], "implicit": True} + assert snap["product"] == {"cell": None, "body": "base", "implicit": True} assert "make_part_rpart" not in path.read_text() # a part holds one solid: the new cylinder says how it meets the body, so no second body is left behind assert service.snapshot()["defaults"]["cylinder"]["operation"] == "join" @@ -538,7 +538,7 @@ def test_a_cell_written_as_code_makes_what_the_kernel_says_it_made(): service._implicit_part = lambda: True # type: ignore[method-assign] snap = service.snapshot() assert snap["bodies"] == [{"name": "path", "produces": "wire"}, {"name": "base", "produces": "solid"}] - assert snap["open"] == ["base"] and snap["product"]["bodies"] == ["base"] + assert snap["open"] == ["base"] and snap["product"]["body"] == "base" assert snap["defaults"]["op.sweep_rsolid"]["path"] == "path" @@ -562,3 +562,49 @@ def test_curves_to_a_flat_face_a_sewn_shell_and_a_solid_run(): exec("\n".join(rendered.params + [rendered.feature]), scope) bodies[slug] = made assert scope["tray"].get_volume() == pytest.approx(40 * 30 * 20, rel=1e-3) + + +def test_several_open_solids_have_no_product_until_the_author_picks_one(tmp_path): + """A Part holds exactly one solid: with a tool left next to its body there is no implicit product, and the editor's + `use_as_product` makes the Part of the solid the author chooses (a make_part_rpart cell).""" + import threading + + import uvicorn + + from simplecadapi.editor.server import WorkspaceState, create_app + from simplecadapi.editor.templates import create_notebook + from simplecadapi.runtime import run_notebook + from simplecadapi.runtime.projection import SeveralOpenBodiesError + + path = create_notebook(tmp_path, "plate", "blank") + state = WorkspaceState(tmp_path) + state.port = 17734 + 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 + service.add({"spec": "box", "name": "base", "values": {}}) + _wait_idle(state) + service.add({"spec": "cylinder", "name": "tool", "values": {"radius": 3.0, "height": 30.0, "operation": "new"}}) # stays beside the body + _wait_idle(state) + snap = service.snapshot() + assert snap["open"] == ["base", "tool"], snap + assert snap["product"] is None # no guess: the author says which solid is the part + assert "make_part_rpart" not in path.read_text() + try: + run_notebook(path, cache=False) + except SeveralOpenBodiesError as error: + assert sorted(error.names) == ["base", "tool"] and "exactly one solid" in str(error) + else: + raise AssertionError("a headless run must not pick a solid for the author") + service.use_as_product({"body": "base"}) + _wait_idle(state) + snap = service.snapshot() + assert snap["product"] is not None and snap["product"]["body"] == "base" and snap["product"]["cell"], snap + assert "make_part_rpart(part_id=scad.notebook_id(), body=base)" in path.read_text() + assert run_notebook(path, cache=False).product.part_id == "plate" + finally: + if state.kernel is not None: + state.kernel.stop() diff --git a/test/test_geometry_interface_quantization.py b/test/test_geometry_interface_quantization.py new file mode 100644 index 00000000..085a43e5 --- /dev/null +++ b/test/test_geometry_interface_quantization.py @@ -0,0 +1,61 @@ +"""The quantizer behind geometry fingerprints must not split values that differ only by computation noise. + +Issue #87: a body's fingerprint changed across a BREP round trip because two edge centers sat on a quantization boundary, +``(k + 0.5) * 1e-7`` mm, and the readback differed from the original by one or two units in the last place. +""" + +from __future__ import annotations + +import math +import random + +import pytest + +from simplecadapi.artifacts.geometry_interface import _q + +TOL = 1.0e-7 + +# The two edge centers of the reporter's R02 body (issue #87): value before the readback, value after, as recorded in the +# issue. Both pairs sit on a boundary and quantized to different integers (-33469081 / -33469082, -66530919 / -66530918). +ISSUE_PAIRS = [ + (-3.3469081499999995, -3.34690815), + (-6.653091850000001, -6.653091849999999), +] + + +@pytest.mark.parametrize("before, after", ISSUE_PAIRS) +def test_values_from_the_issue_quantize_alike(before: float, after: float) -> None: + assert _q(before, TOL) == _q(after, TOL) + + +def test_noise_around_a_boundary_never_splits_a_value() -> None: + rng = random.Random(87) + split = 0 + for _ in range(20000): + magnitude = 10 ** rng.uniform(-3, 4) # mm: a hair to ten metres + k = int(magnitude / TOL) + exact = (k + 0.5) * TOL * rng.choice((-1, 1)) + a, b = exact, exact + for _ in range(rng.randint(0, 3)): # up to three ulps of computation noise, each side + a = math.nextafter(a, rng.choice((-math.inf, math.inf))) + b = math.nextafter(b, rng.choice((-math.inf, math.inf))) + split += _q(a, TOL) != _q(b, TOL) + assert split == 0 + + +def test_ordinary_values_quantize_as_before() -> None: + """The snap window is far narrower than a quantum, so values away from a boundary keep their old integer. + + Fingerprints are stored in artifacts: a rule that moved many integers would invalidate them for no reason.""" + rng = random.Random(5) + moved = 0 + for _ in range(50000): + value = rng.uniform(-200.0, 200.0) + moved += _q(value, TOL) != int(round(value / TOL)) + assert moved <= 5 # about 1e-5 of the values lie within the snap window of a boundary + + +def test_quantization_is_antisymmetric_and_exact_on_integers() -> None: + for k in (0, 1, 7, 123456789): + assert _q(k * TOL, TOL) == k + assert _q(-k * TOL, TOL) == -k diff --git a/test/test_multi_body_part.py b/test/test_multi_body_part.py deleted file mode 100644 index 62426415..00000000 --- a/test/test_multi_body_part.py +++ /dev/null @@ -1,376 +0,0 @@ -"""Multi-body parts: one Part, several named solids kept apart (not an assembly). - -The spike of design-docs/multi-solid-design.md: a Part made from ``bodies={"base": ..., "lid": ...}`` keeps both -solids, previews them as two nodes, packages each with its own BRep and topology snapshot, exports one STEP product -holding both, reports the mass of each body, and survives the notebook cell cache. One-body Parts are unchanged. -""" - -from __future__ import annotations - -import copy -import pickle -import re -import tempfile -import unittest -from pathlib import Path - -import pytest - -import simplecadapi as scad -from simplecadapi.artifacts import materialize_definition -from simplecadapi.exporter.step import export_product_package_to_step -from simplecadapi.product.packages import build_product_package, encode_product_package, load_product_package -from simplecadapi.runtime import run_notebook -from simplecadapi.scene.preview import compile_preview, decode_preview_header, encode_preview, entity_info - -from test_notebook_runtime import NotebookTestCase, cell, notebook_text - - -def _base() -> scad.Solid: - return scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") - - -def _lid() -> scad.Solid: - return scad.make_box_rsolid(20.0, 20.0, 2.0, (40.0, 0.0, 0.0), tag_prefix="lid") - - -def _case() -> scad.Part: - return scad.make_part_rpart(part_id="case", bodies={"base": _base(), "lid": _lid()}) - - -def _tags(solid: scad.Solid) -> set[str]: - return {tag for face in solid.faces() for tag in face._tags} - - -class TestMultiBodyPartValue(unittest.TestCase): - def test_bodies_are_kept_apart_in_order(self) -> None: - case = _case() - self.assertTrue(case.multi_body) - self.assertEqual(case.body_ids(), ("base", "lid")) - self.assertEqual([type(body).__name__ for body in case.bodies], ["Body", "Body"]) - self.assertAlmostEqual(case.get_body("lid").solid.get_volume(), 800.0, places=6) - self.assertIn("lid.face.top", _tags(case.get_body("lid").solid)) - self.assertEqual(case.to_dict()["bodies"], [{"body_id": "base", "name": None}, {"body_id": "lid", "name": None}]) - - def test_body_is_refused_on_a_multi_body_part(self) -> None: - with self.assertRaisesRegex(TypeError, r"has 2 bodies \(base, lid\)"): - _case().body - with self.assertRaises(KeyError): - _case().get_body("hinge") - - def test_bodies_may_be_body_values_with_names(self) -> None: - case = scad.make_part_rpart( - part_id="case", bodies=[scad.Body("base", _base(), "Base"), scad.Body("lid", _lid(), name="Lid")] - ) - self.assertEqual([body.name for body in case.bodies], ["Base", "Lid"]) - - def test_bad_bodies_are_refused(self) -> None: - with self.assertRaises(Exception): - scad.make_part_rpart(part_id="case", body=_base(), bodies={"lid": _lid()}) - with self.assertRaises(Exception): - scad.make_part_rpart(part_id="case") - with self.assertRaisesRegex(Exception, "duplicate body_id"): - scad.Part(part_id="case", bodies=[scad.Body("a", _base()), scad.Body("a", _lid())]) - with self.assertRaises(Exception): - scad.make_part_rpart(part_id="case", bodies={"not an id": _base(), "lid": _lid()}) - with self.assertRaises(Exception): - scad.make_part_rpart(part_id="case", bodies={"base": _base(), "lid": "lid"}) - - def test_a_one_body_part_is_what_it_always_was(self) -> None: - part = scad.make_part_rpart(part_id="plate", body=_base()) - self.assertFalse(part.multi_body) - self.assertEqual(part.body_ids(), ("body",)) - self.assertTrue(part.body.same_topology(part.bodies[0].solid)) - self.assertNotIn("bodies", part.to_dict()) - - def test_pickle_and_deepcopy_keep_every_body_and_its_tags(self) -> None: - case = _case().with_material(scad.make_material_rmaterial(material_id="pp", density=900.0, density_unit="kg/m^3")) - for again in (pickle.loads(pickle.dumps(case)), copy.deepcopy(case)): - self.assertEqual(again.body_ids(), ("base", "lid")) - self.assertEqual(again.material.material_id, "pp") - self.assertEqual(_tags(again.get_body("lid").solid), _tags(case.get_body("lid").solid)) - one = pickle.loads(pickle.dumps(scad.make_part_rpart(part_id="plate", body=_base()))) - self.assertFalse(one.multi_body) - - def test_material_and_connectors(self) -> None: - material = scad.make_material_rmaterial(material_id="al", density=2.7, density_unit="g/cm^3") - case = scad.assign_material_rpart(_case(), material) - self.assertEqual(case.body_ids(), ("base", "lid")) - connector = scad.make_placement_connector_rconnector( - connector_id="mount", placement=scad.identity_placement_rplacement() - ) - with self.assertRaisesRegex(Exception, "multi-body"): - scad.add_connector_rpart(case, connector) - - def test_an_assembly_of_a_multi_body_part_flattens_every_body(self) -> None: - rig = scad.make_assembly_rassembly(assembly_id="rig") - rig = scad.add_component_rassembly( - assembly=rig, item=_case(), component_id="case", placement=scad.make_placement_rplacement(origin=(0, 0, 5)) - ) - compound = scad.make_compound_from_assembly_rcompound(assembly=rig) - self.assertEqual(len(compound.solids()), 2) - - -class TestMultiBodyReplay(unittest.TestCase): - def test_a_recorded_multi_body_part_replays_with_its_bodies(self) -> None: - with scad.GraphSession() as session: - _case() - replayed = [item for item in scad.replay_model_json(scad.export_model_json(session)) if isinstance(item, scad.Part)] - self.assertEqual([part.body_ids() for part in replayed], [("base", "lid")]) - self.assertAlmostEqual(replayed[0].get_body("lid").solid.get_volume(), 800.0, places=6) - - -class TestMultiBodyMass(unittest.TestCase): - def test_mass_of_each_body_and_of_the_part(self) -> None: - material = scad.make_material_rmaterial(material_id="al", density=2.7, density_unit="g/cm^3") - report = scad.measure_part_mass_rmassreport(scad.assign_material_rpart(_case(), material)) - self.assertEqual([item.body_id for item in report.bodies], ["base", "lid"]) - self.assertAlmostEqual(report.body("base").volume_mm3, 4000.0, places=6) - self.assertAlmostEqual(report.body("base").mass_kg, 4000.0 * 2.7e-6, places=12) - self.assertAlmostEqual(report.body("lid").mass_kg, 800.0 * 2.7e-6, places=12) - self.assertAlmostEqual(report.mass_kg, 4800.0 * 2.7e-6, places=12) - self.assertAlmostEqual(report.body("lid").centroid_mm[0], 40.0, places=6) - self.assertAlmostEqual(report.centroid_mm[0], 800.0 * 40.0 / 4800.0, places=6) - - def test_without_a_density_masses_are_none(self) -> None: - report = scad.measure_part_mass_rmassreport(_case()) - self.assertIsNone(report.mass_kg) - self.assertAlmostEqual(report.volume_mm3, 4800.0, places=6) - override = scad.measure_part_mass_rmassreport(_case(), density=1000.0) - self.assertAlmostEqual(override.body("lid").mass_kg, 800.0e-6, places=12) - - def test_a_one_body_part_has_one_row(self) -> None: - report = scad.measure_part_mass_rmassreport(scad.make_part_rpart(part_id="plate", body=_base())) - self.assertEqual([item.body_id for item in report.bodies], ["body"]) - - -class TestMultiBodyPreview(unittest.TestCase): - def test_each_body_is_a_node_with_its_own_entities(self) -> None: - scene = compile_preview(_case()) - header = decode_preview_header(encode_preview(scene)) - self.assertEqual([node["node_id"] for node in header["nodes"]], ["node/case/body:base", "node/case/body:lid"]) - self.assertEqual({part["body_id"] for part in header["parts"]}, {"base", "lid"}) - lid = next(part for part in header["parts"] if part["body_id"] == "lid") - self.assertTrue(lid["solid_entity_id"].startswith("entity/case/body:lid/solid/")) - self.assertTrue(all(g["entity_id"].startswith("entity/case/body:lid/face/") for g in lid["face_groups"])) - info = entity_info(scene, lid["solid_entity_id"]) - self.assertAlmostEqual(info["properties"]["volume"], 800.0, places=6) - face = entity_info(scene, lid["face_groups"][0]["entity_id"]) - self.assertEqual(face["kind"], "face") - self.assertTrue(any(tag.startswith("lid.face.") for tag in face["tags"])) - - def test_two_bodies_of_the_same_shape_stay_two(self) -> None: - a = scad.make_box_rsolid(5.0, 5.0, 5.0) - b = scad.make_box_rsolid(5.0, 5.0, 5.0) - scene = compile_preview(scad.make_part_rpart(part_id="pair", bodies={"a": a, "b": b})) - self.assertEqual(len(scene.nodes), 2) - self.assertEqual(len(scene.parts), 2) - - def test_a_one_body_part_previews_as_before(self) -> None: - header = decode_preview_header(encode_preview(compile_preview(scad.make_part_rpart(part_id="plate", body=_base())))) - self.assertEqual([node["node_id"] for node in header["nodes"]], ["node/plate"]) - self.assertNotIn("body_id", header["parts"][0]) - self.assertTrue(header["parts"][0]["solid_entity_id"].startswith("entity/plate/solid/")) - - -CASE = notebook_text( - cell( - """ - base = scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") - lid = scad.make_box_rsolid(20.0, 20.0, 2.0, (40.0, 0.0, 0.0), tag_prefix="lid") - """, - returns="base, lid", - ), - cell( - 'case = scad.make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid})', - reads="base, lid", - returns="case", - ), - table='id = "case"', -) - - -class TestMultiBodyNotebook(NotebookTestCase): - def setUp(self) -> None: - super().setUp() - self.case = self.write("case.py", CASE) - - def test_the_cell_cache_restores_a_multi_body_product(self) -> None: - first = run_notebook(self.case) - second = run_notebook(self.case) - self.assertEqual(self.statuses(second), ["ran", "cached", "cached"]) - self.assertEqual(first.definition.content_hash, second.definition.content_hash) - self.assertEqual([item.body_id for item in second.definition.bodies], ["base", "lid"]) - - def test_the_package_holds_both_bodies_with_their_tags(self) -> None: - definition = run_notebook(self.case).definition - manifest = definition.to_dict() - self.assertEqual([row["body_id"] for row in manifest["solid_cache"]["bodies"]], ["base", "lid"]) - loaded = load_product_package(encode_product_package(build_product_package(definition))) - part = materialize_definition(loaded) # the package's root definition, read back from its bytes - self.assertEqual(part.body_ids(), ("base", "lid")) - self.assertIn("lid.face.top", _tags(part.get_body("lid").solid)) - self.assertIn("base.face.top", _tags(part.get_body("base").solid)) - self.assertAlmostEqual(scad.measure_part_mass_rmassreport(part).body("lid").volume_mm3, 800.0, places=6) - - def test_the_scene_draws_each_body_as_a_solid_node(self) -> None: - from simplecadapi.scene.product_scene import compile_product_scene - - manifest = compile_product_scene(run_notebook(self.case).definition).manifest - nodes = {node["node_id"]: node for node in manifest["nodes"]} - self.assertIsNone(nodes["node/case"]["geometry_asset_id"]) - self.assertEqual(nodes["node/case/body:lid"]["node_kind"], "solid") - self.assertEqual(nodes["node/case/body:lid"]["parent_node_id"], "node/case") - self.assertIsNotNone(nodes["node/case/body:base"]["geometry_asset_id"]) - self.assertNotEqual(nodes["node/case/body:base"]["entity_asset_id"], nodes["node/case/body:lid"]["entity_asset_id"]) - - def test_step_export_writes_one_product_with_both_solids(self) -> None: - data = encode_product_package(build_product_package(run_notebook(self.case).definition)) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "case.step" - export_product_package_to_step(data, path) - text = path.read_text(encoding="utf-8", errors="replace") - self.assertEqual(len(re.findall(r"MANIFOLD_SOLID_BREP", text)), 2) - self.assertEqual(len(re.findall(r"= PRODUCT\(", text)), 1) - - def test_a_one_body_definition_has_no_bodies_list(self) -> None: - self.write( - "plate.py", - notebook_text( - cell("plate = scad.make_part_rpart(part_id=scad.notebook_id(), body=scad.make_box_rsolid(5.0, 5.0, 1.0))", returns="plate"), - table='id = "plate"', - ), - ) - definition = run_notebook(self.root / "plate.py").definition - self.assertNotIn("bodies", definition.to_dict()["solid_cache"]) - self.assertEqual(definition.bodies, ()) - - -class TestSplitCutsAndOverlaps(unittest.TestCase): - """Owner decisions: a splitting cut is not refused; overlapping bodies warn but are allowed.""" - - @staticmethod - def _bar_and_slot() -> tuple[scad.Solid, scad.Solid]: - return scad.make_box_rsolid(40.0, 10.0, 10.0, tag_prefix="bar"), scad.make_box_rsolid(4.0, 20.0, 20.0, (0.0, -5.0, -5.0)) - - def test_a_splitting_cut_returns_its_pieces_in_centroid_order(self) -> None: - bar, slot = self._bar_and_slot() - pieces = scad.cut_rsolid(bar, slot) - self.assertIsInstance(pieces, list) - self.assertEqual([round(p.get_center().x, 6) for p in pieces], [-11.0, 11.0]) - with self.assertRaisesRegex(Exception, "split the body"): - scad.cut_rsolid(*self._bar_and_slot(), on_split="error") - - def test_pieces_become_bodies(self) -> None: - bar, slot = self._bar_and_slot() - pieces = scad.cut_rsolid(bar, slot) - self.assertEqual(scad.make_part_rpart(part_id="bar", body=pieces).body_ids(), ("body_1", "body_2")) - lid = _lid() - part = scad.make_part_rpart(part_id="bar", bodies={"bar": pieces, "lid": lid}) - self.assertEqual(part.body_ids(), ("bar_1", "bar_2", "lid")) - self.assertAlmostEqual(part.get_body("bar_2").solid.get_volume(), 1800.0, places=6) - - def test_a_split_replays_with_the_same_piece_order(self) -> None: - with scad.GraphSession() as session: - bar, slot = self._bar_and_slot() - scad.make_part_rpart(part_id="bar", body=scad.cut_rsolid(bar, slot)) - (part,) = [x for x in scad.replay_model_json(scad.export_model_json(session)) if isinstance(x, scad.Part)] - self.assertEqual(part.body_ids(), ("body_1", "body_2")) - self.assertLess(part.get_body("body_1").solid.get_center().x, part.get_body("body_2").solid.get_center().x) - - def test_overlaps_are_reported_and_warned_not_refused(self) -> None: - a = scad.make_box_rsolid(10.0, 10.0, 10.0) - b = scad.make_box_rsolid(10.0, 10.0, 10.0, (5.0, 0.0, 0.0)) - c = scad.make_box_rsolid(10.0, 10.0, 10.0, (15.0, 0.0, 0.0)) # touches b only along a face - part = scad.make_part_rpart(part_id="stack", bodies={"a": a, "b": b, "c": c}) - overlaps = scad.inspect_body_overlaps_rlist(part) - self.assertEqual([(o.first, o.second) for o in overlaps], [("a", "b")]) - self.assertAlmostEqual(overlaps[0].volume_mm3, 500.0, places=6) - - @scad.part(id="stack") - def stack() -> scad.Part: - return scad.make_part_rpart( - part_id="stack", - bodies={"a": scad.make_box_rsolid(10.0, 10.0, 10.0), "b": scad.make_box_rsolid(10.0, 10.0, 10.0, (5.0, 0.0, 0.0))}, - ) - - with self.assertWarns(scad.BodyOverlapWarning): - result = stack() - self.assertEqual([item.body_id for item in result.definition.bodies], ["a", "b"]) - - def test_part_from_bodies_is_the_implicit_part_rule(self) -> None: - from simplecadapi.product.bodies import part_from_bodies - - one = part_from_bodies("plate", {"plate": _base()}) - self.assertEqual(one.body_ids(), ("body",)) - bar, slot = self._bar_and_slot() - split = part_from_bodies("bar", {"bar": scad.cut_rsolid(bar, slot)}) - self.assertEqual(split.body_ids(), ("bar_1", "bar_2")) - both = part_from_bodies("case", {"base": _base(), "lid": _lid()}) - self.assertEqual(both.body_ids(), ("base", "lid")) - - -class TestProfileThree(unittest.TestCase): - def test_a_multi_body_definition_is_profile_3_and_a_profile_2_reader_refuses_it(self) -> None: - from simplecadapi.artifacts.canonical import ArtifactValidationError - from simplecadapi.artifacts.validation import _schema_validate, validate_manifest - - @scad.part(id="case") - def case() -> scad.Part: - return _case() - - manifest = case().definition.to_dict() - self.assertEqual(manifest["profile"], "simplecad-part-definition-3") - validate_manifest(manifest) # this SDK reads it - with self.assertRaises(ArtifactValidationError): # what an SDK that knows only profile 2 does - _schema_validate({**manifest, "profile": "simplecad-part-definition-2"}, "part_definition") - - @scad.part(id="plate") - def plate() -> scad.Part: - return scad.make_part_rpart(part_id="plate", body=_base()) - - self.assertEqual(plate().definition.to_dict()["profile"], "simplecad-part-definition-2") - - -if __name__ == "__main__": - unittest.main() - - -def test_a_fillet_or_chamfer_on_the_pieces_of_a_split_body_blends_each_piece(): - """A cut that splits a body gives its pieces; the blends take them, each piece gets the edges on it.""" - with scad.GraphSession(graph_id="blend-pieces"): - pieces = scad.cut_rsolid(scad.make_box_rsolid(40.0, 10.0, 10.0), scad.make_box_rsolid(2.0, 20.0, 20.0)) - assert isinstance(pieces, list) and len(pieces) == 2 - top = scad.ql.edges().where(scad.ql.prop("geom.center.z", ">=", 9.9)) - rounded = scad.fillet_rsolid(pieces, top.exactly(8), 1.0) - assert len(rounded) == 2 and all(r.get_volume() < p.get_volume() for r, p in zip(rounded, pieces)) - bevelled = scad.chamfer_rsolid(pieces, top, 0.5) - assert len(bevelled) == 2 and all(b.get_volume() < p.get_volume() for b, p in zip(bevelled, pieces)) - # edges of one piece only: the other piece is kept as it is - left = [e for e in top.resolve(pieces[0])] - one = scad.fillet_rsolid(pieces, left, 1.0) - assert one[1].get_volume() == pytest.approx(pieces[1].get_volume()) and one[0].get_volume() < pieces[0].get_volume() - with pytest.raises(ValueError, match="expected .*exactly.*5.*found 8"): - scad.fillet_rsolid(pieces, top.exactly(5), 1.0) - assert isinstance(scad.fillet_rsolid(scad.make_box_rsolid(10.0, 10.0, 10.0), top, 1.0), scad.Solid) # a Solid stays a Solid - - -def test_an_operation_on_the_pieces_of_a_split_body_applies_to_every_piece(): - """Pieces flow on: a one-shape operation runs per piece, a selection is checked over all of them.""" - with scad.GraphSession(graph_id="op-pieces"): - pieces = scad.cut_rsolid(scad.make_box_rsolid(40.0, 10.0, 10.0), scad.make_box_rsolid(2.0, 20.0, 20.0)) - assert isinstance(pieces, scad.Pieces) - moved = scad.translate_shape(shape=pieces, vector=(0.0, 0.0, 5.0)) - assert isinstance(moved, scad.Pieces) and [round(m.get_center().z, 6) for m in moved] == [10.0, 10.0] - drilled = scad.cut_rsolid(pieces, scad.make_cylinder_rsolid(1.0, 30.0, (10.0, 0.0, -5.0))) - assert len(drilled) == 2 and drilled[1].get_volume() < pieces[1].get_volume() - assert drilled[0].get_volume() == pytest.approx(pieces[0].get_volume()) - tops = scad.ql.faces().where(scad.ql.prop("geom.center.z", ">=", 9.9)) - assert len(scad.shell_rsolid(pieces, tops.exactly(2), 1.0)) == 2 - with pytest.raises(ValueError, match="found 2"): - scad.shell_rsolid(pieces, tops.exactly(1), 1.0) - assert [b.body_id for b in scad.make_part_rpart(part_id="x", body=moved).bodies] == ["body_1", "body_2"] - assert isinstance(pickle.loads(pickle.dumps(pieces)), scad.Pieces) - # a plain list is still a list of tools: a - b - c - box = scad.make_box_rsolid(10.0, 10.0, 10.0) - assert isinstance(scad.cut_rsolid([box, scad.make_box_rsolid(2.0, 2.0, 20.0)]), scad.Solid) diff --git a/test/test_notebook_runtime.py b/test/test_notebook_runtime.py index f87d21d3..755616dd 100644 --- a/test/test_notebook_runtime.py +++ b/test/test_notebook_runtime.py @@ -13,6 +13,7 @@ import importlib.metadata import io import json +import os import pickle import re import sys @@ -326,6 +327,33 @@ def test_without_the_cache_nothing_is_stored(self) -> None: self.assertFalse((state_dir(run.config) / "cache").exists()) self.assertEqual(self.statuses(run_notebook(self.bracket)), ["ran"] * 4) + def test_without_the_cache_the_source_directory_is_left_alone(self) -> None: + """Issue #89: a run that keeps no state must not create the state directory (``__marimo__``) next to the source, + so a notebook can run where the source directory is read-only (a container's ``-v src:/src:ro``).""" + + run_notebook(self.bracket, cache=False) + self.assertFalse((self.root / "__marimo__").exists()) + self.assertFalse(state_dir(read_notebook_config(self.bracket)).exists()) + + @unittest.skipUnless( + os.name == "posix" and hasattr(os, "geteuid") and os.geteuid() != 0, "needs a non-root POSIX user to make a directory read-only" + ) + def test_without_the_cache_a_read_only_source_directory_runs(self) -> None: + directories = [self.root, self.root / "lib"] + modes = [directory.stat().st_mode for directory in directories] + for directory in directories: + directory.chmod(0o555) + try: + run = run_notebook(self.bracket, cache=False) + finally: + for directory, mode in zip(directories, modes): + directory.chmod(mode) + self.assertEqual(run.definition.definition_id, "bracket") + + def test_with_the_cache_the_dependency_record_is_still_written(self) -> None: + run = run_notebook(self.bracket) + self.assertTrue((state_dir(run.config) / "deps.json").is_file()) + def test_overrides_skip_the_defining_cell(self) -> None: plain = run_notebook(self.bracket) wide = run_notebook(self.bracket, overrides={"width": 30.0}) diff --git a/test/test_one_solid_part.py b/test/test_one_solid_part.py new file mode 100644 index 00000000..04e0128f --- /dev/null +++ b/test/test_one_solid_part.py @@ -0,0 +1,353 @@ +"""A Part holds exactly one solid; several solids side by side are loose notebook values. + +The decision (design-docs/part-holds-one-solid.md): a Part is the unit an Assembly places, mates and gives connectors +to, so it is one solid. The solids the editor needs next to each other while a model is built (a tool and its target, +the tools of a pattern before the cut) are notebook values: they are previewed side by side, and a notebook that ends +with several of them and no Part has no product (``SeveralOpenBodiesError``) until the author chooses one. + +This file replaces ``test_multi_body_part.py``. Its split-cut tests (``Pieces``) stay: pieces are notebook values. +""" + +from __future__ import annotations + +import pickle +import unittest + +import pytest + +import simplecadapi as scad +from simplecadapi.runtime import run_notebook +from simplecadapi.runtime.projection import SeveralOpenBodiesError +from simplecadapi.scene.preview import compile_open_bodies, compile_preview, decode_preview_header, encode_preview, entity_info + +from test_notebook_runtime import NotebookTestCase, cell, notebook_text + + +def _base() -> scad.Solid: + return scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") + + +def _lid() -> scad.Solid: + return scad.make_box_rsolid(20.0, 20.0, 2.0, (40.0, 0.0, 0.0), tag_prefix="lid") + + +class TestAPartIsOneSolid(unittest.TestCase): + def test_a_part_has_one_body(self) -> None: + part = scad.make_part_rpart(part_id="plate", body=_base()) + self.assertEqual(part.body_ids(), ("body",)) + self.assertEqual(len(part.bodies), 1) + self.assertTrue(part.body.same_topology(part.bodies[0].solid)) + self.assertFalse(hasattr(part, "multi_body")) + self.assertNotIn("bodies", part.to_dict()) + + def test_several_solids_are_refused_with_what_to_do(self) -> None: + for build in ( + lambda: scad.make_part_rpart(part_id="case", body=[_base(), _lid()]), # the pieces of a split cut + lambda: scad.Part(part_id="case", body=[_base(), _lid()]), + ): + with self.assertRaisesRegex(Exception, r"exactly one solid.*one Part per"): + build() + + def test_the_bodies_argument_no_longer_exists(self) -> None: + with self.assertRaises(TypeError): + scad.make_part_rpart(part_id="case", bodies={"base": _base(), "lid": _lid()}) + with self.assertRaises(TypeError): + scad.Part(part_id="case", bodies=[scad.Body("a", _base())]) + with self.assertRaises(Exception): + scad.make_part_rpart(part_id="case") # a body is required + + def test_one_solid_in_a_list_or_a_body_is_accepted(self) -> None: + self.assertEqual(scad.make_part_rpart(part_id="one", body=[_base()]).body_ids(), ("body",)) + named = scad.Part(part_id="one", body=scad.Body("body", _base(), "Base", color=(0.1, 0.2, 0.3), opacity=0.5)) + self.assertEqual((named.bodies[0].name, named.bodies[0].color, named.bodies[0].opacity), ("Base", (0.1, 0.2, 0.3), 0.5)) + + def test_pickle_and_deepcopy_keep_the_body_and_its_tags(self) -> None: + part = scad.make_part_rpart(part_id="plate", body=_base()) + again = pickle.loads(pickle.dumps(part)) + self.assertEqual(again.body_ids(), ("body",)) + self.assertIn("base.face.top", {tag for face in again.body.faces() for tag in face._tags}) + + def test_the_mass_report_has_one_row(self) -> None: + material = scad.make_material_rmaterial(material_id="al", density=2.7, density_unit="g/cm^3") + report = scad.measure_part_mass_rmassreport(scad.assign_material_rpart(scad.make_part_rpart(part_id="base", body=_base()), material)) + self.assertEqual([item.body_id for item in report.bodies], ["body"]) + self.assertAlmostEqual(report.mass_kg, 4000.0 * 2.7e-6, places=12) + + def test_the_removed_helpers_are_gone(self) -> None: + for name in ("part_shape", "inspect_body_overlaps_rlist", "BodyOverlap", "BodyOverlapWarning"): + self.assertFalse(hasattr(scad, name), name) + + +class TestEveryPartCanJoinAnAssembly(unittest.TestCase): + """What the multi-body Part could not do (it refused connectors and made explode_rassembly raise).""" + + def _rig(self) -> scad.Assembly: + mount = scad.make_placement_connector_rconnector(connector_id="mount", placement=scad.identity_placement_rplacement()) + base = scad.add_connector_rpart(scad.make_part_rpart(part_id="base", body=_base()), mount) + lid = scad.add_connector_rpart(scad.make_part_rpart(part_id="lid", body=_lid()), mount) + rig = scad.make_assembly_rassembly(assembly_id="rig") + rig = scad.add_component_rassembly(assembly=rig, item=base, component_id="base", placement=scad.identity_placement_rplacement()) + return scad.add_component_rassembly( + assembly=rig, item=lid, component_id="lid", placement=scad.make_placement_rplacement(origin=(0, 0, 10)) + ) + + def test_a_part_takes_connectors(self) -> None: + part = scad.add_connector_rpart( + scad.make_part_rpart(part_id="base", body=_base()), + scad.make_placement_connector_rconnector(connector_id="mount", placement=scad.identity_placement_rplacement()), + ) + self.assertEqual(part.connector_ids(), ("mount",)) + + def test_the_assembly_flattens_explodes_and_previews(self) -> None: + rig = self._rig() + self.assertEqual(len(scad.make_compound_from_assembly_rcompound(assembly=rig).solids()), 2) + exploded = scad.explode_rassembly(rig, 1.0) + self.assertEqual(set(exploded.component_ids()), {"base", "lid"}) + header = decode_preview_header(encode_preview(compile_preview(rig))) + self.assertEqual([node["node_id"] for node in header["nodes"]], ["node/rig/base", "node/rig/lid"]) + + def test_several_solids_are_delivered_as_an_assembly_of_parts(self) -> None: + rig = scad.make_assembly_rassembly(assembly_id="case") + for name, solid in (("base", _base()), ("lid", _lid())): + rig = scad.add_component_rassembly( + assembly=rig, + item=scad.make_part_rpart(part_id=name, body=solid), + component_id=name, + placement=scad.identity_placement_rplacement(), + ) + self.assertEqual(len(scad.make_compound_from_assembly_rcompound(assembly=rig).solids()), 2) + + +class TestReplay(unittest.TestCase): + def test_a_recorded_part_replays(self) -> None: + with scad.GraphSession() as session: + scad.make_part_rpart(part_id="plate", body=_base()) + replayed = [item for item in scad.replay_model_json(scad.export_model_json(session)) if isinstance(item, scad.Part)] + self.assertEqual([part.body_ids() for part in replayed], [("body",)]) + self.assertAlmostEqual(replayed[0].body.get_volume(), 4000.0, places=6) + + def test_a_model_recorded_with_several_solids_in_one_part_is_refused(self) -> None: + import json + + with scad.GraphSession() as session: + scad.make_part_rpart(part_id="plate", body=_base()) + model = json.loads(scad.export_model_json(session)) + node = next(n for n in model["graph"]["nodes"] if n.get("op") == "make_part_rpart") if "graph" in model else None + if node is None: + self.skipTest("the model JSON layout has no graph/nodes with ops to edit") + params = node.setdefault("params", {}) + params["bodies"] = [{"body_id": "a", "name": None}, {"body_id": "b", "name": None}] + with self.assertRaisesRegex(Exception, "exactly one solid"): + scad.replay_model_json(json.dumps(model)) + + +class TestOpenBodiesPreview(unittest.TestCase): + """The editor's several solids side by side: drawn as nodes of the notebook, not as the bodies of a Part.""" + + def test_each_open_solid_is_a_node_with_its_own_entities(self) -> None: + scene = compile_open_bodies("nb", {"base": _base(), "lid": _lid()}) + header = decode_preview_header(encode_preview(scene)) + self.assertEqual([node["node_id"] for node in header["nodes"]], ["node/nb/body:base", "node/nb/body:lid"]) + self.assertEqual({part["body_id"] for part in header["parts"]}, {"base", "lid"}) + lid = next(part for part in header["parts"] if part["body_id"] == "lid") + self.assertTrue(lid["solid_entity_id"].startswith("entity/nb/body:lid/solid/")) + info = entity_info(scene, lid["solid_entity_id"]) + self.assertAlmostEqual(info["properties"]["volume"], 800.0, places=6) + face = entity_info(scene, lid["face_groups"][0]["entity_id"]) + self.assertEqual(face["kind"], "face") + self.assertTrue(any(tag.startswith("lid.face.") for tag in face["tags"])) + + def test_two_solids_of_the_same_shape_stay_two(self) -> None: + scene = compile_open_bodies("pair", {"a": scad.make_box_rsolid(5.0, 5.0, 5.0), "b": scad.make_box_rsolid(5.0, 5.0, 5.0)}) + self.assertEqual((len(scene.nodes), len(scene.parts)), (2, 2)) + + def test_a_part_previews_as_one_node(self) -> None: + header = decode_preview_header(encode_preview(compile_preview(scad.make_part_rpart(part_id="plate", body=_base())))) + self.assertEqual([node["node_id"] for node in header["nodes"]], ["node/plate"]) + self.assertNotIn("body_id", header["parts"][0]) + self.assertTrue(header["parts"][0]["solid_entity_id"].startswith("entity/plate/solid/")) + + def test_nothing_to_draw_is_an_error(self) -> None: + with self.assertRaises(Exception): + compile_open_bodies("nb", {}) + + +def _part_notebook(*cells: str, notebook_id: str = "demo") -> str: + return notebook_text(*cells, table=f'id = "{notebook_id}"\nkind = "part"') + + +TWO_OPEN = _part_notebook( + cell( + """ + base = scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") + tool = scad.make_cylinder_rsolid(3.0, 30.0, (10.0, 10.0, -5.0)) + """, + returns="base, tool", + ), +) + +ONE_OPEN = _part_notebook(cell("base = scad.make_box_rsolid(20.0, 20.0, 10.0)", returns="base")) + +CHOSEN = _part_notebook( + cell( + """ + base = scad.make_box_rsolid(20.0, 20.0, 10.0, tag_prefix="base") + tool = scad.make_cylinder_rsolid(3.0, 30.0, (10.0, 10.0, -5.0)) + """, + returns="base, tool", + ), + cell("demo = scad.make_part_rpart(part_id=scad.notebook_id(), body=base)", reads="base", returns="demo"), +) + +CUT = _part_notebook( + cell( + """ + base = scad.make_box_rsolid(20.0, 20.0, 10.0) + tool = scad.make_cylinder_rsolid(3.0, 30.0, (10.0, 10.0, -5.0)) + """, + returns="base, tool", + ), + cell("bored = scad.cut_rsolid(base, tool)", reads="base, tool", returns="bored"), +) + + +class TestNotebookProduct(NotebookTestCase): + def test_one_open_solid_is_the_implicit_part(self) -> None: + product = run_notebook(self.write("one.py", ONE_OPEN.replace('"demo"', '"one"'))).product + self.assertEqual((product.part_id, product.body_ids()), ("one", ("body",))) + + def test_several_open_solids_have_no_product(self) -> None: + with self.assertRaises(SeveralOpenBodiesError) as caught: + run_notebook(self.write("two.py", TWO_OPEN)) + self.assertEqual(sorted(caught.exception.names), ["base", "tool"]) # the order of names defined in one cell is not fixed + message = str(caught.exception) + self.assertIn("exactly one solid", message) + self.assertIn("make_part_rpart", message) + + def test_the_tool_cut_into_the_body_leaves_one_open_solid(self) -> None: + # the example of the design: a tool and its target exist together, then the cut leaves one solid + product = run_notebook(self.write("cut.py", CUT)).product + self.assertEqual(product.body_ids(), ("body",)) + self.assertLess(product.body.get_volume(), 20 * 20 * 10) + + def test_a_part_made_of_the_chosen_solid_is_the_product(self) -> None: + product = run_notebook(self.write("chosen.py", CHOSEN)).product + self.assertAlmostEqual(product.body.get_volume(), 4000.0, places=6) + self.assertIn("base.face.top", {tag for face in product.body.faces() for tag in face._tags}) + + def test_the_package_holds_one_solid_and_exports_to_step(self) -> None: + import re + import tempfile + from pathlib import Path + + from simplecadapi.exporter.step import export_product_package_to_step + from simplecadapi.product.packages import build_product_package, encode_product_package + + definition = run_notebook(self.write("chosen.py", CHOSEN)).definition + manifest = definition.to_dict() + self.assertEqual(manifest["profile"], "simplecad-part-definition-2") + self.assertNotIn("bodies", manifest["solid_cache"]) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "demo.step" + export_product_package_to_step(encode_product_package(build_product_package(definition)), path) + text = path.read_text(encoding="utf-8", errors="replace") + self.assertEqual(len(re.findall(r"MANIFOLD_SOLID_BREP", text)), 1) + + def test_the_cell_cache_restores_the_product(self) -> None: + path = self.write("chosen.py", CHOSEN) + first = run_notebook(path) + second = run_notebook(path) + self.assertEqual(first.definition.content_hash, second.definition.content_hash) + self.assertIn("cached", self.statuses(second)) + + def test_the_live_preview_shows_the_open_solids(self) -> None: + from simplecadapi.runtime.live import preview_product + from simplecadapi.runtime.projection import OpenBodies + + from simplecadapi.runtime import config as runtime_config + + path = self.write("two.py", TWO_OPEN) + config = runtime_config.read_notebook_config(path) + base, tool = _base(), scad.make_cylinder_rsolid(3.0, 30.0, (10.0, 10.0, -5.0)) + product, partial = preview_product(config, [], {"base": base, "tool": tool}) + self.assertIsInstance(product, OpenBodies) + self.assertIsNone(partial) + self.assertEqual(list(product.bodies), ["base", "tool"]) + + +class TestSplitCutsStillGivePieces(unittest.TestCase): + """Pieces are notebook values (where several solids belong); a Part takes one of them.""" + + @staticmethod + def _bar_and_slot() -> tuple[scad.Solid, scad.Solid]: + return scad.make_box_rsolid(40.0, 10.0, 10.0, tag_prefix="bar"), scad.make_box_rsolid(4.0, 20.0, 20.0, (0.0, -5.0, -5.0)) + + def test_a_splitting_cut_returns_its_pieces_in_centroid_order(self) -> None: + bar, slot = self._bar_and_slot() + pieces = scad.cut_rsolid(bar, slot) + self.assertIsInstance(pieces, list) + self.assertEqual([round(p.get_center().x, 6) for p in pieces], [-11.0, 11.0]) + with self.assertRaisesRegex(Exception, "split the body"): + scad.cut_rsolid(*self._bar_and_slot(), on_split="error") + + def test_a_piece_is_a_part_and_pieces_are_not(self) -> None: + bar, slot = self._bar_and_slot() + pieces = scad.cut_rsolid(bar, slot) + with self.assertRaisesRegex(Exception, "exactly one solid"): + scad.make_part_rpart(part_id="bar", body=pieces) + left = scad.make_part_rpart(part_id="bar_left", body=pieces[0]) + self.assertAlmostEqual(left.body.get_volume(), 1800.0, places=6) + + def test_a_split_replays_with_the_same_piece_order(self) -> None: + with scad.GraphSession() as session: + bar, slot = self._bar_and_slot() + pieces = scad.cut_rsolid(bar, slot) + scad.make_part_rpart(part_id="bar", body=pieces[0]) + (part,) = [x for x in scad.replay_model_json(scad.export_model_json(session)) if isinstance(x, scad.Part)] + self.assertLess(part.body.get_center().x, 0.0) + + +if __name__ == "__main__": + unittest.main() + + +def test_a_fillet_or_chamfer_on_the_pieces_of_a_split_body_blends_each_piece(): + """A cut that splits a body gives its pieces; the blends take them, each piece gets the edges on it.""" + with scad.GraphSession(graph_id="blend-pieces"): + pieces = scad.cut_rsolid(scad.make_box_rsolid(40.0, 10.0, 10.0), scad.make_box_rsolid(2.0, 20.0, 20.0)) + assert isinstance(pieces, list) and len(pieces) == 2 + top = scad.ql.edges().where(scad.ql.prop("geom.center.z", ">=", 9.9)) + rounded = scad.fillet_rsolid(pieces, top.exactly(8), 1.0) + assert len(rounded) == 2 and all(r.get_volume() < p.get_volume() for r, p in zip(rounded, pieces)) + bevelled = scad.chamfer_rsolid(pieces, top, 0.5) + assert len(bevelled) == 2 and all(b.get_volume() < p.get_volume() for b, p in zip(bevelled, pieces)) + # edges of one piece only: the other piece is kept as it is + left = [e for e in top.resolve(pieces[0])] + one = scad.fillet_rsolid(pieces, left, 1.0) + assert one[1].get_volume() == pytest.approx(pieces[1].get_volume()) and one[0].get_volume() < pieces[0].get_volume() + with pytest.raises(ValueError, match="expected .*exactly.*5.*found 8"): + scad.fillet_rsolid(pieces, top.exactly(5), 1.0) + assert isinstance(scad.fillet_rsolid(scad.make_box_rsolid(10.0, 10.0, 10.0), top, 1.0), scad.Solid) # a Solid stays a Solid + + +def test_an_operation_on_the_pieces_of_a_split_body_applies_to_every_piece(): + """Pieces flow on: a one-shape operation runs per piece, a selection is checked over all of them.""" + with scad.GraphSession(graph_id="op-pieces"): + pieces = scad.cut_rsolid(scad.make_box_rsolid(40.0, 10.0, 10.0), scad.make_box_rsolid(2.0, 20.0, 20.0)) + assert isinstance(pieces, scad.Pieces) + moved = scad.translate_shape(shape=pieces, vector=(0.0, 0.0, 5.0)) + assert isinstance(moved, scad.Pieces) and [round(m.get_center().z, 6) for m in moved] == [10.0, 10.0] + drilled = scad.cut_rsolid(pieces, scad.make_cylinder_rsolid(1.0, 30.0, (10.0, 0.0, -5.0))) + assert len(drilled) == 2 and drilled[1].get_volume() < pieces[1].get_volume() + assert drilled[0].get_volume() == pytest.approx(pieces[0].get_volume()) + tops = scad.ql.faces().where(scad.ql.prop("geom.center.z", ">=", 9.9)) + assert len(scad.shell_rsolid(pieces, tops.exactly(2), 1.0)) == 2 + with pytest.raises(ValueError, match="found 2"): + scad.shell_rsolid(pieces, tops.exactly(1), 1.0) + # several pieces are not one Part: a Part holds exactly one solid + with pytest.raises(Exception, match="exactly one solid"): + scad.make_part_rpart(part_id="x", body=moved) + assert isinstance(pickle.loads(pickle.dumps(pieces)), scad.Pieces) + # a plain list is still a list of tools: a - b - c + box = scad.make_box_rsolid(10.0, 10.0, 10.0) + assert isinstance(scad.cut_rsolid([box, scad.make_box_rsolid(2.0, 2.0, 20.0)]), scad.Solid) diff --git a/test/test_product_exporter.py b/test/test_product_exporter.py index 11cd34ae..6999a9db 100644 --- a/test/test_product_exporter.py +++ b/test/test_product_exporter.py @@ -147,7 +147,7 @@ def build_root() -> scad.Assembly: return scad.build_product_package(build_root()) -def _build_cylinder_package(): +def _build_cylinder_result(): @scad.part( id="cylinder", project_root=Path(__file__).parent, @@ -156,7 +156,11 @@ def build_cylinder() -> scad.Part: body = scad.make_cylinder_rsolid(radius=4.0, height=8.0) return scad.make_part_rpart("cylinder", body) - return scad.build_product_package(build_cylinder()) + return build_cylinder() + + +def _build_cylinder_package(): + return scad.build_product_package(_build_cylinder_result()) class TestProductExporter(unittest.TestCase): @@ -182,6 +186,39 @@ def test_exporters_are_available_only_from_exporter_namespace(self): self.assertFalse(hasattr(scad, "export_obj")) self.assertFalse(hasattr(scad.translator, "ap242_translator")) + def test_a_build_result_exports_in_one_step_without_a_scene(self): + """Issue #89: a script that only wants a file need not capture a ``.scadpkg`` first, nor pay for the scene + (the tessellated projection no exporter reads).""" + + from unittest import mock + + from simplecadapi.product import packages + + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + with mock.patch.object(packages, "_scene_projection", side_effect=AssertionError("the scene was built")): + step = scad.exporter.export_product_package_to_step(result, root / "one.step") + stl = scad.exporter.export_product_package_to_stl(result, root / "one.stl") + self.assertEqual(step.definition_ids, ("cylinder",)) + self.assertGreater((root / "one.step").stat().st_size, 0) + self.assertGreater((root / "one.stl").stat().st_size, 0) + self.assertEqual(stl.output_path, (root / "one.stl").resolve()) + + def test_a_definition_exports_like_its_build_result(self): + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + from_result = scad.exporter.export_product_package_to_step(result, root / "a.step") + from_definition = scad.exporter.export_product_package_to_step(result.definition, root / "b.step") + self.assertEqual(from_result.definition_ids, from_definition.definition_ids) + self.assertEqual(from_result.metadata_item_count, from_definition.metadata_item_count) + + def test_a_value_that_is_no_product_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp_dir: + with self.assertRaises(TypeError): + scad.exporter.export_product_package_to_step(object(), Path(tmp_dir) / "x.step") + def test_nested_package_preserves_ap242_product_structure_and_placements(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) diff --git a/tools/build_backend/scad_build.py b/tools/build_backend/scad_build.py new file mode 100644 index 00000000..874adf8b --- /dev/null +++ b/tools/build_backend/scad_build.py @@ -0,0 +1,129 @@ +"""PEP 517 build backend: scikit-build-core, plus the toolchain/OCCT detection a plain ``uv sync`` needs. + +Why this exists: the package contains a C++ module (src/cpp) that needs a compiler, a build generator and an OpenCASCADE +install. scikit-build-core leaves all three to CMake's defaults, and on a Windows machine without Visual Studio the default +is the NMake generator, which then fails ("'nmake' -? failed"), and nothing tells CMake where OCCT is. Contributors had to +export CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS by hand. This wrapper does that detection itself and then delegates every hook +to scikit-build-core. Anything the user already set (CMAKE_GENERATOR, CC, CXX, SCAD_OCCT_PREFIX, CMAKE_ARGS with a generator +or an OCCT prefix, CMAKE_BUILD_PARALLEL_LEVEL) is left alone, so CI (cibuildwheel) and Linux/macOS builds behave as before. + +Detection runs only on Windows. Linux and macOS use the system OCCT (or CI's) through CMake's own search. +""" +from __future__ import annotations + +import ctypes +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from scikit_build_core import build as _skbuild + +# Everything scikit-build-core exposes (get_requires_for_*, prepare_metadata_*, build_sdist, ...) passes straight through. +from scikit_build_core.build import * # noqa: F401,F403 + +REPO = Path(__file__).resolve().parents[2] +OCCT_TAG = "V7_9_3" # tools/build_occt.py's DEFAULT_TAG: the kernel generation the SDK ships + + +def _log(msg: str) -> None: + print(f"[scad_build] {msg}", file=sys.stderr, flush=True) + + +def _has_msvc() -> bool: + """True when Visual Studio's C++ toolset is installed (CMake's Visual Studio generator finds it by itself).""" + if shutil.which("cl"): + return True + pf = os.environ.get("ProgramFiles(x86)") + vswhere = Path(pf, "Microsoft Visual Studio", "Installer", "vswhere.exe") if pf else None + if vswhere and vswhere.is_file(): + out = subprocess.run( + [str(vswhere), "-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], + capture_output=True, text=True, check=False, + ).stdout.strip() + return bool(out) + return False + + +def _total_memory_gb() -> float: + class _Status(ctypes.Structure): + _fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong), ("total_phys", ctypes.c_ulonglong), + ("avail_phys", ctypes.c_ulonglong), ("total_page", ctypes.c_ulonglong), + ("avail_page", ctypes.c_ulonglong), ("total_virt", ctypes.c_ulonglong), + ("avail_virt", ctypes.c_ulonglong), ("avail_ext", ctypes.c_ulonglong)] + + status = _Status() + status.length = ctypes.sizeof(_Status) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined] + return status.total_phys / 2**30 + + +def _find_occt() -> Path | None: + """An OCCT install prefix (the directory holding lib/cmake/opencascade), looked up in the usual places.""" + candidates = [os.environ.get("SCAD_OCCT_PREFIX"), "C:/occt", str(_occt_cache_dir())] + for c in candidates: + if c and (Path(c) / "lib" / "cmake" / "opencascade").is_dir(): + return Path(c) + return None + + +def _occt_cache_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / "simplecadapi" / f"occt-{OCCT_TAG}" + + +def _build_occt(prefix: Path) -> None: + _log(f"no OpenCASCADE found; building {OCCT_TAG} into {prefix} (one time, 20-40 minutes, cached for every later build)") + subprocess.run([sys.executable, str(REPO / "src" / "cpp" / "tools" / "build_occt.py"), "--prefix", str(prefix), "--tag", OCCT_TAG], check=True) + + +def _prepare_windows_environment() -> None: + env = os.environ + cmake_args = env.get("CMAKE_ARGS", "") + + # 1. generator + compiler + if not env.get("CMAKE_GENERATOR") and "-G" not in cmake_args and not _has_msvc(): + gxx, gcc, ninja = shutil.which("g++"), shutil.which("gcc"), shutil.which("ninja") + if not (gxx and gcc): + raise RuntimeError( + "Building simplecadapi needs a C++ compiler on Windows: install Visual Studio Build Tools (C++ workload) " + "or a MinGW-w64 gcc (e.g. `scoop install gcc`) and re-run." + ) + # ninja is injected by scikit-build-core from the build requirements when the generator is Ninja + env["CMAKE_GENERATOR"] = "Ninja" + env.setdefault("CC", gcc) + env.setdefault("CXX", gxx) + _log(f"Visual Studio not found; using Ninja + {gxx}" + ("" if ninja else " (ninja from the build requirements)")) + + # 2. OpenCASCADE + if "SCAD_OCCT_PREFIX" not in cmake_args: + prefix = _find_occt() + if prefix is None: + prefix = _occt_cache_dir() + _build_occt(prefix) + # OpenCASCADE_DIR too: CMake's package registry may otherwise offer a stale OCCT left in %TEMP% by an old build + cmake_args += f" -DSCAD_OCCT_PREFIX={prefix.as_posix()} -DOpenCASCADE_DIR={(prefix / 'lib' / 'cmake' / 'opencascade').as_posix()}" + env["CMAKE_ARGS"] = cmake_args.strip() + _log(f"OpenCASCADE: {prefix}") + + # 3. parallelism: one g++ translating OCCT-heavy bindings needs ~2 GB, so a many-core machine with little RAM ran out of + # memory ("cc1plus: out of memory") under ninja's default of cpu_count + 2 jobs + if not env.get("CMAKE_BUILD_PARALLEL_LEVEL"): + jobs = max(1, min(os.cpu_count() or 2, int(_total_memory_gb() // 2.5))) + env["CMAKE_BUILD_PARALLEL_LEVEL"] = str(jobs) + + +def _prepare() -> None: + if sys.platform == "win32": + _prepare_windows_environment() + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_editable(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_editable(wheel_directory, config_settings, metadata_directory) diff --git a/uv.lock b/uv.lock index e1313337..a597e696 100644 --- a/uv.lock +++ b/uv.lock @@ -2996,7 +2996,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.8" +version = "2.2.9" source = { editable = "." } dependencies = [ { name = "cryptography" },