Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
654a6d3
part: a Part holds exactly one solid (the constructor refuses several…
NiJingzhe Nov 9, 2026
2c99826
python glue + replay: follow the one-solid Part (pickle through Body,…
NiJingzhe Nov 9, 2026
9fb9401
artifacts: a part definition holds one solid (drop BodyRef, `solid_ca…
NiJingzhe Nov 9, 2026
6c49c88
design-docs: record the decision that a Part holds exactly one solid …
NiJingzhe Nov 9, 2026
90fe84a
runtime + preview: several open solids are shown side by side, but ar…
NiJingzhe Nov 9, 2026
2029bee
scene + exporters: a Part is drawn, exported and measured from its on…
NiJingzhe Nov 9, 2026
84aab45
api: remove the helpers that only existed for a Part of several bodies
NiJingzhe Nov 9, 2026
d415647
editor: several open solids have no product until the author picks on…
NiJingzhe Nov 9, 2026
f016a14
editor-web: the "model shown" log says what the header says (open sol…
NiJingzhe Nov 9, 2026
910df75
AGENT.md: record how to rebuild the C++ module here, and why the obvi…
NiJingzhe Nov 9, 2026
0307aaa
tests: replace the multi-body Part tests by tests of the one-solid ru…
NiJingzhe Nov 9, 2026
7e2e996
cpp: comments no longer describe multi-body Parts
NiJingzhe Nov 9, 2026
ba19f43
cleanup: remove the package's per-body blob path check, and comments …
NiJingzhe Nov 9, 2026
6441107
docs/api: the API pages say a Part holds exactly one solid (drop `bod…
NiJingzhe Nov 9, 2026
fb50a97
docs/skill: domain, discipline, workflow and guide pages follow the o…
NiJingzhe Nov 9, 2026
01852ed
docs/skill: scadpkg-format says a part definition holds one solid (no…
NiJingzhe Nov 9, 2026
d0040a7
changelog + SDK_DEFECTS: record that a Part holds one solid again (an…
NiJingzhe Nov 9, 2026
a0923a6
docstrings: the source of the API pages say a Part holds one solid (a…
NiJingzhe Nov 9, 2026
b56634e
tests: the cell-cache test of restored Pieces makes its Part from one…
NiJingzhe Nov 9, 2026
f60f698
tests: the editor's API index test expects Part to take `body` and no…
NiJingzhe Nov 9, 2026
0a961d1
AGENT.md: write down how commits are organised (one decision per comm…
NiJingzhe Nov 9, 2026
695a03a
cpp: format part.hpp and bind_product_values.cpp with clang-format 18…
NiJingzhe Nov 9, 2026
5efd5bb
tests: do not depend on the order of the names a single cell defines …
NiJingzhe Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,66 @@ 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.

- Use the project's own path, `uv pip install --python .venv/Scripts/python.exe -e . --no-deps -v`. It reuses
`src/cpp/build/wheel/<tag>/` (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=<scoop gcc>/gcc.exe CXX=<scoop gcc>/g++.exe
CMAKE_ARGS="-DCMAKE_MAKE_PROGRAM=<scoop shims>/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/<tag>/bindings/python/_native.<tag>.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/<name>.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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and

## Unreleased

### 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.

## 2.2.8 — 2026-11-09

User-facing notes: [`docs/updates/2.2.8.md`](docs/updates/2.2.8.md).
Expand Down
4 changes: 4 additions & 0 deletions design-docs/multi-solid-design.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
98 changes: 98 additions & 0 deletions design-docs/part-holds-one-solid.md
Original file line number Diff line number Diff line change
@@ -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/<notebook id>/body:<name>`), 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.
5 changes: 2 additions & 3 deletions docs/skill/references/discipline/failure-and-repair.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/skill/references/discipline/mechanical-modeling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
21 changes: 12 additions & 9 deletions docs/skill/references/discipline/notebook-cell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<name>_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
Expand Down
4 changes: 2 additions & 2 deletions docs/skill/references/docs/api/Body.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading