Skip to content

A Part holds exactly one solid (withdraw the multi-body Part); open solids are shown, not a Part - #83

Merged
NiJingzhe merged 23 commits into
devfrom
fix/part-holds-one-solid
Nov 9, 2026
Merged

NiJingzhe merged 23 commits into
devfrom
fix/part-holds-one-solid

Conversation

@NiJingzhe

Copy link
Copy Markdown
Collaborator

What

A Part holds exactly one solid again. The multi-body Part added after 2.2.7 (and first shipped in the withdrawn 2.2.8 release) is removed.

Several solids that exist side by side while a model is built (a tool next to its target; pattern a tool, then cut) are now notebook values: the editor draws them as "N open solids". A notebook that ends with several of them and no Part has no product (SeveralOpenBodiesError, which names the solids and says what to do) until the author chooses one (use_as_product in the editor, or make_part_rpart(body=...) in code). A deliverable of several solids is an Assembly of Parts.

Why (the discussion this comes from)

  • The editor needs several solids in one notebook with no assembly constraint (pattern a tool, then cut). The Part was the only product value, so it was made to hold several bodies.
  • The owner's judgement: that makes assembly hard and was a wrong decision made for the editor's convenience.
  • Agreed analysis: those solids are intermediate values of a notebook, not product items. A Part is the unit an Assembly places, mates and gives connectors to, so it is one solid; the editor's need is a preview need.

The audit of the 2.2.8 release found, in code and by running it:

  • connectors on a multi-body Part were refused outright, so it could not take part in mates or public connectors;
  • explode_rassembly raised TypeError on any assembly holding one;
  • scene/compiler.py read part.body in four places.

Full account: design-docs/part-holds-one-solid.md (first commit of the series that adds it; design-docs/ is git-ignored, tracked with -f). Every commit message states its own decision and reasons; the series is one decision per commit.

Changes

  • C++: Part constructor refuses anything but one solid; make_part_rpart / Part lose bodies=; Part(body=Body) keeps name / colour / opacity through pickle.
  • Python: glue and replay (a model recorded with several solids in one Part is refused), artifacts (no profile 3, no solid_cache.bodies), scene and exporters read the one body, removed inspect_body_overlaps_rlist, BodyOverlap, BodyOverlapWarning, part_shape, part_from_bodies.
  • Runtime / preview / editor: SeveralOpenBodiesError, OpenBodies, compile_open_bodies; editor use_as_product for a notebook with several open solids; header and log say "N open solids".
  • Tests: test_multi_body_part.py -> test_one_solid_part.py (also covers connectors and explode on every Part); test_body_appearance.py rewritten; three other tests follow the new rule.
  • Docs: API pages (regenerated from docstrings), skill pages, scadpkg-format, CHANGELOG, AGENT.md (build notes, commit rule).

Compatibility

bodies=, Part.multi_body and the removed helpers only existed since the multi-body work (after 2.2.7), so no released version loses them. Part definitions stay profile 2.

Checked

  • Native module rebuilt and exercised directly; Python tests of the touched areas pass; editor-web vitest (331) and tsc --noEmit pass.
  • Driven in a real browser (ego-browser): a part notebook with two open solids shows "2 open solids" and no Part; with a make_part_rpart cell it shows "1 part".
  • Full local suite on Windows: 2613 passed; the remaining failures are Windows-environment ones (encodings, CRLF, locked files, paths) and one tolerance test that fails the same way with the previous native module. C++ doctests were not built locally (SCAD_BUILD_TESTS is off in the local build); CI runs them.

🤖 Generated with Claude Code

NiJingzhe and others added 23 commits November 10, 2026 03:48
…; make_part_rpart and Part lose `bodies=`)

DECISION: the product item `Part` is one solid again. The multi-body Part added on 2026-11-04
(b607c85, design-docs/multi-solid-design.md) is withdrawn. This commit is the C++ half; the Python
consumers, the editor and the docs follow in the later commits of this series, one decision each.

WHY - THE DISCUSSION THIS DECISION COMES FROM (owner and assistant, 2026-11-10)
1. Why a Part was ever allowed several solids (the owner's account). The editor must support several
   solids existing in one notebook with no assembly constraint between them: to pattern a tool and then
   cut, there is necessarily a moment where more than one tool and the body exist together. That is easy
   to understand and it is a real need. At the time the Part was the only product value a notebook could
   end in, so the Part was made to hold several named bodies.
2. Why it is the wrong place (the owner's judgement). It makes assembly hard. The whole design was made
   for a human-friendly editor and it led to a wrong decision: a multi-solid Part. The question put was:
   should we go back to "a Part has exactly one solid" and support the editor some other, better way?
3. The assistant's analysis, which the owner accepted. 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" were two levels that got mixed. The editor's need is a PREVIEW /
   notebook-value need (show every open solid), and a deliverable that really is several solids is an
   Assembly of Parts. So: Part = one solid; the editor shows open solids as loose values (later commit).
4. Urgency. The owner asked for the audit and the change at once because the multi-body Part might be a
   malignant bug in the released 2.2.8; its GitHub release was deleted on 2026-11-10 while this was done.

WHAT THE AUDIT FOUND (checked on 2026-11-10 against the code and by running it)
- Connectors on a multi-body Part were refused outright (Part constructor, part.cpp), so no mate, no
  public connector and no ConnectorRef could ever reach one: it could join an assembly only as a rigid
  blob placed by its origin.
- explode_rassembly raised TypeError on any assembly that held one (ops_explode.cpp collect_box called
  Part::body(), which throws for a multi-body Part).
- The scene compiler scene/compiler.py read part.body in four places (compile_scene fails on a
  multi-body Part). Every consumer needed a second code path, and some were missed. Not yet checked:
  joint / constraint solving, interference and MJCF paths (they depend on connectors anyway).

ALTERNATIVE CONSIDERED AND REJECTED: keep multi-body Parts and finish stage 3 of the design (a
connector anchored by body_id + entity). It changes the part-definition contract, the interface and
binding hashes and the entity bindings, and it still leaves a concept that overlaps with Assembly (a
multi-body part is "an assembly without constraints").

WHAT THIS COMMIT DOES
- The invariant is enforced in the lowest layer so that every consumer can rely on it instead of each
  branching on `multi_body`: Part(std::vector<Body>) throws ValueError unless there is exactly one body,
  and the message says what to do (one Part per solid in an Assembly, or union / cut to one solid).
  Part::multi_body() is gone.
- Binding: make_part_rpart(part_id, body, name) and Part(part_id, body, ...) take one Solid, one Body, or
  a list of exactly one piece. `bodies=` is removed (TypeError: unexpected keyword); more than one piece
  is a ValueError with the same guidance. Part(body=Body) is how a body that carries a name / colour /
  opacity survives pickling and copying (it replaces Part(bodies=(Body,))).
- The make_part_rpart(vector<Body>) overload and the mapping / sequence parsing helpers are removed.

KEPT ON PURPOSE: Body (it carries the body's display name, colour and opacity), Part::bodies() (always
one element), body_ids(), get_body(), set_body_appearance_rpart: one-body Parts already use them and
removing them would only churn the one-body path, which did not break.

Verified with the native module directly: a Solid, a one-element list and a Body are accepted; `bodies=`,
two solids and Part(body=[a, b]) are refused with the guidance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… refuse a recorded multi-solid make_part_rpart)

DECISION: the Python value layer and the model-JSON replay follow the rule of 654a6d3 (a Part holds
exactly one solid). No behaviour is added; two places that knew about several bodies are narrowed.

WHY (the discussion in short; the full account is in 654a6d3): the several-solids-in-one-Part design
existed so that the editor could hold a tool and its target side by side with no assembly constraint
(pattern a tool, then cut). The owner judged that a wrong design for an editor's convenience, because it
makes assembly hard; the conclusion reached with the assistant is that those solids are intermediate
values of a notebook, not product items, so a Part is one solid again. Everything below only removes what
assumed the opposite.

- _glue/values.py (pickle / deepcopy of a Part): a Part used to be rebuilt from either `body` or
  `bodies`. Now `body` is always the argument: the Solid when the body is plain, and the `Body` when it
  carries a name, colour or opacity, so an appearance still survives the notebook cell cache
  (Part(body=Body) is accepted by the binding for exactly this).
- _glue/session.py: a Part's children for lineage are its one body solid, its connectors and material.
- recording/serializer.py (replay of make_part_rpart): the multi-body branch is gone. A model JSON
  recorded by a development build that held several solids in one Part (params["bodies"] with more than
  one row; only SDK 2.2.8 development builds ever recorded it, 2.2.7 never did) now fails with a message
  that says what to do instead of being replayed into something the SDK no longer has. The unused Body
  import is removed.

Why refuse instead of converting such a model to one Part per solid: the part ids and the assembly that
would hold them are the author's decision, not something replay can invent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…che.bodies` and profile 3)

DECISION: the persistent PartDefinition goes back to exactly one evaluated Solid, which is what it was in
SDK 2.2.7. The multi-body extension (b607c85, 9778a8e) is removed together with the Part rule of
654a6d3.

WHY (the discussion in short; the full account is in 654a6d3): the owner explained that a Part was made
multi-body only because the editor must show several solids side by side in one notebook with no
assembly constraint (pattern a tool, then cut), and that this was a wrong design because it makes
assembly hard. The conclusion with the assistant: those solids are notebook intermediates, not product
items; a Part is one solid and an Assembly is how several solids are delivered. The durable format of a
Part therefore has no reason to describe several bodies.

Why remove the reader instead of keeping it for files already written
- profile `simplecad-part-definition-3` and `solid_cache.bodies` were added after 2.2.7 and first shipped
  in 2.2.8, whose GitHub release was withdrawn on 2026-11-10 (the multi-body Part could not join an
  assembly). Nothing durable in the wild is written with it that we promise to read.
- A reader for a profile that the writer can never produce again is dead code that must be kept
  consistent (connector checks, appearance rules, blob membership) for ever.

What changes
- artifacts/part_definition.py: BodyRef and the `bodies` field are gone; the profile is always
  simplecad-part-definition-2; the body appearance block (`appearance.bodies`, from
  set_body_appearance_rpart) stays, at most one row, and that row carries the name again.
  with_definition_appearance gives a Solid, or a Body when the definition has an appearance.
- artifacts/canonical.py, artifacts/validation.py: the multi-body profile constant and the per-profile
  schema dispatch are removed; contracts/part-definition-3.schema.json is deleted.
- artifacts/part_io.py: read_part_bodies -> read_part_body (one Solid, restored with its topology
  snapshot); the archive member set no longer lists per-body blobs. assembly_io.py builds the Part with
  Part(body=...).
- build/definition.py: one BRep and one snapshot; no overlap warning, no Compound view, no body blobs.

part-definition-2.schema.json is untouched: it already allows the appearance block and its
solid_cache has additionalProperties false, so it never allowed `solid_cache.bodies`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…(and mark multi-solid-design.md superseded)

DECISION: write the decision down in the repository, with the discussion it came from, so that a person
who was not in the conversation can take over and trace it.

WHY: the multi-body Part was a design choice made for the editor's convenience, withdrawn on 2026-11-10 after
the owner and the assistant talked it through (the owner's reasons: the editor needs several solids side by
side with no assembly constraint, e.g. pattern a tool and cut; but making that a property of the Part makes
assembly hard and was a wrong decision; the assistant's analysis: those solids are notebook intermediates,
not product items, so Part = one solid and the editor shows loose solids). The owner also asked that every
decision be its own commit with its reasons. Commit messages carry the short form; this file carries the
whole chain, the audit table of what was broken in 2.2.8, the rules after the decision, and the list of
commits.

- design-docs/part-holds-one-solid.md: new; supersedes the older document. (design-docs/ is git-ignored, but
  the older design document is tracked, so this one is added the same way, with -f.)
- design-docs/multi-solid-design.md: a note at the top says it is superseded and where to read instead; the
  body is left as it was (the record of what was tried).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e no longer a Part

DECISION: the editor's need (several solids existing in one notebook with no assembly constraint) is met
in the notebook / preview layer, not by the Part. A `kind = "part"` notebook that ends with exactly one open
solid still gets the implicit one-body Part; with several open solids no product is chosen for the author.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The owner's need: the editor must support several solids in one notebook with no assembly constraint, e.g.
pattern a tool and then cut - for a moment there are more than one tool and the body. The multi-body Part
was invented to hold them and was judged wrong because it makes assembly hard. The agreed analysis: those
solids are intermediate values of a notebook, not product items, so the editor's real need is to SHOW every
open solid. This commit is that replacement: it keeps what the editor needed and removes it from the Part.

WHAT CHANGES
- runtime/projection.py
  - the implicit Part of a part notebook is made from its one open body. Several open solids (a variable
    per solid, the pieces of a split cut counted as solids) raise the new SeveralOpenBodiesError, a
    ProductNotFoundError that names the solids and says what to do: make the Part from the solid that is
    the part, or make a Part of each and an Assembly of them. `sca run`, export and every caller that needs
    a product therefore stop with an actionable message instead of silently choosing or fusing.
  - OpenBodies (part_id + solids by name) and loose_solids() are the value a preview draws in that case.
  - project_product rebuilds the implicit Part with make_part_rpart(body=...) (it used the multi-body rule).
- runtime/live.py: preview_product catches SeveralOpenBodiesError and returns OpenBodies, so the live
  preview of an unfinished notebook (a tool next to its target) keeps working, including when a later cell
  failed (the partial-model report names the shown solids as before).
- scene/preview.py
  - compile_open_bodies(part_id, solids): one node per solid, `node/<notebook id>/body:<name>`, entity
    namespace `entity/<notebook id>/body:<name>/...` (two solids of the same shape stay two). This is the
    same wire shape the multi-body preview produced, deliberately: editor-web, element references
    (live_refs) and entity_info need no change.
  - compile_preview draws a Part as one node; its multi-body branch is removed.

WHY NOT choose one open solid automatically (e.g. the last): the tip is a guess, and exporting a Part that
silently left a solid out is worse than an error; the author knows which solid is the part.

Checked by running: one open body -> Part; two open bodies -> SeveralOpenBodiesError with the guidance;
compile_open_bodies header / entity_info for two same-shape solids; a one-body Part previews as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e body (no per-body branches)

DECISION: every consumer of a Part reads `part.body` again. The code that treated a multi-body Part as a
set of child solid nodes, a Compound view or a list of named / coloured STEP solids is removed.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The multi-body Part was judged a wrong design (it was made so the editor could hold several solids
with no assembly constraint, and it makes assembly hard); the agreed fix is Part = one solid, and the
editor's several solids become loose preview values (commit "runtime + preview"). The audit of 2.2.8
showed what a second code path per consumer costs: each consumer had to branch on `multi_body`, and some
did not (explode, the legacy scene compiler), which is how the release came to have crashes. With the
invariant enforced in the Part constructor these branches can never run, so they are deleted rather than
kept as dead code.

WHAT CHANGES
- scene/product_scene.py: the package scene has one GLB and one entity document per Part again;
  _body_geometries and the `node/<part>/body:<id>` child solid nodes are removed; _part_geometry no
  longer refuses; _appearance_properties reads the part's one appearance row.
  (The package-scene node tree of a one-body part is what it always was.)
- exporter/step.py: the STEP product's shape is `part.body`; the body colour overrides the material colour
  as before; the `bodies` list of named / coloured solids is no longer built for a Part. The STEP writer's
  generic support for named solids (io::StepBody in C++) is left in place: it is the writer's ability, it
  has its own tests, and removing it would be a separate decision.
- exporter/mjcf.py: the collision / visual mesh of a Part is `part.body`.
- scene/prewarm.py, exporter/_surface_mesh.py, build/definition.py: only the comments that said "every
  body of a multi-body part" (the loops over `part.bodies` stay: it has one element).

Checked: test_product_exporter, test_product_packages, test_scene_preview, test_scene_compiler and
test_notebook_runtime pass. test_body_appearance fails on its multi-body fixtures; the tests are updated in a
later commit of the series.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
DECISION: delete inspect_body_overlaps_rlist, BodyOverlap, BodyOverlapWarning, part_shape and
part_from_bodies from the SDK, and narrow the text of measure_part_mass_rmassreport to one body.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
A Part is one solid again, because the several solids the editor needs side by side are notebook values, not
product items, and the multi-body Part made assembly hard. Each of these helpers answered a question that
only exists when a Part holds several bodies:
- inspect_body_overlaps_rlist / BodyOverlap / BodyOverlapWarning: "do two bodies of one Part share volume?"
  (a Part of one solid has none; checking loose solids against each other is a different, not requested,
  feature);
- part_shape: "the Part as one shape, a Compound for several bodies" (it is `part.body`);
- part_from_bodies: "the Part a notebook's named solids make" (the new rule is in runtime/projection.py:
  one open solid is the Part, several are an error that says what to do).
They were public (`scad.*`) only since the multi-body work (after 2.2.7), and were first shipped by the
withdrawn 2.2.8 release, so removing them breaks no supported release.

WHAT CHANGES
- product/bodies.py keeps body_display_color, body_appearance_fields, BodyMass, MassReport and
  measure_part_mass_rmassreport (the report has one row: the Part's body; `.bodies` is still a tuple so a
  caller that read `report.body(id)` or `report.bodies[0]` keeps working).
- scad.__init__, operators/*, _glue/modules.json, _glue/docstrings.json (one entry removed, the mass entry
  re-derived from the new docstring) and the replay table of recording/serializer.py (the diagnostic
  entry of the removed operation) no longer list them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e (use_as_product), and say so

DECISION: the editor keeps letting solids exist side by side (a tool next to its body), but they are no longer
the bodies of one implicit Part. With exactly one open solid the Part is implicit as before; with several the
notebook has no product yet and the editor asks the author which solid is the part.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
This is the editor need that started the whole multi-body Part: several solids in one notebook with no
assembly constraint (pattern a tool, then cut). The owner's judgement was that solving it inside the Part
makes assembly hard and was wrong; the agreed answer was "a Part is one solid, and the editor supports the
several solids some other way". This commit is that other way at the editor: the solids stay visible and
editable (the preview draws them, commit "runtime + preview"), and the product is chosen, never guessed. A
guess would silently leave a solid out of the exported Part; the author knows which solid is the part.

WHAT CHANGES
- editor/gui_features.py
  - snapshot(): the implicit product exists only for exactly one open solid (and its dict loses `bodies`);
    several open solids give `product: null` while `open` still lists them.
  - use_as_product(): when the notebook has no product, a part notebook and a body that is open, it writes
    the product cell `<stem> = scad.make_part_rpart(part_id=scad.notebook_id(), body=<chosen>)`. Before, it
    refused ("no product cell"), which would have left the author without a way out.
- editor-web
  - FeatureTree: a notice with one "use <solid>" button per open solid while there is no product and
    several solids are open ("N solids are open, and a Part holds exactly one solid. Choose the part:").
    The product badge reads the one product body.
  - features.ts: FeatureState.product loses `bodies`.
  - preview.ts previewSummary: nodes `node/<id>/body:<name>` are now the notebook's open solids, not the
    bodies of a part, so the header says "N open solids" (zh-CN strings added) instead of "1 part · N bodies".
- tests: test_editor / test_editor_features follow the new product dict and the required `body` argument
  of Part; a new test drives the editor flow end to end (two open solids -> no product, a headless run
  raises SeveralOpenBodiesError naming them -> use_as_product -> the Part exists and the notebook runs).

Checked: editor-web vitest (331 tests) and tsc --noEmit pass; the editor test files pass except for the
multi-body fixtures of other files, which are updated in the tests commit of the series.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ids are not parts)

DECISION: the log line written when a preview arrives reuses previewSummary(), so it counts parts and open
solids the same way as the header.

WHY: found by driving the real editor in a browser (ego-browser) after the editor commit of this series.
A part notebook with two open solids (a body and a tool, no Part yet) showed "2 open solids" in the header
but "model shown: 2 parts" in the log: the log still counted every node as a part, which was true while a
Part could hold several bodies and is false now that a Part holds exactly one solid (see
design-docs/part-holds-one-solid.md; the owner's point was that the editor's side-by-side solids are
notebook values, not parts, so the editor must not call them parts).

- App.tsx: log.push("ok", ...) uses t("model shown: {{summary}}", { summary: previewSummary(nodes) }).
- zh-CN.json: the two plural strings it replaces are one string with the summary.

Checked in the browser: a part notebook with two open solids -> header "2 open solids", log "model shown: 2
open solids"; the same notebook with a make_part_rpart cell -> header "1 part". editor-web vitest (331) and
tsc --noEmit pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ous commands fail

DECISION: write the build experience of this series into AGENT.md, as that file asks ("if you have any
experience during working, write it down here, with detailed reasons").

WHY: the owner asked for a one-solid Part to be implemented urgently (see design-docs/part-holds-one-solid.md),
which needed a rebuilt native module, and the first three build attempts failed for reasons the errors do not
show: a CMakeCache pointing into a deleted temporary build environment, the NMake generator being chosen when
none is configured, a stale OpenCASCADE found in %TEMP%, and the installed .pyd being locked by the owner's
running editor. The next person (or agent) to change src/cpp on this machine should not rediscover them. The
same note records the web UI rebuild, the `npx tsc` trap, `git add -f` for design-docs, and that
docstrings.json shadows source docstrings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…le (and of what the audit found broken)

DECISION: test_multi_body_part.py becomes test_one_solid_part.py (git rename), and test_body_appearance.py is
rewritten for the single body of a Part. The tests now state the rule of this series instead of the withdrawn
design.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The owner's need was several solids side by side in one notebook with no assembly constraint (pattern a tool
and cut); the multi-body Part was a wrong way to give it, because it made assembly hard; the agreed rule is
Part = one solid, the editor shows loose solids, an Assembly delivers several. A rule that is not tested
comes back: the audit of 2.2.8 found a multi-body Part refusing connectors and crashing explode_rassembly,
and nothing had said so. So the tests include those two cases, now for every Part.

test_one_solid_part.py
- a Part has one body; several solids / `bodies=` are refused with the guidance; one solid in a list or a
  Body is accepted; pickle keeps body and tags; the mass report has one row; the removed helpers are gone;
- every Part takes connectors, and an assembly of such Parts flattens, explodes (the audit's crash) and
  previews; several solids are delivered as an Assembly of Parts;
- replay: a recorded Part replays, and a model recorded with several solids in one Part is refused;
- preview: compile_open_bodies draws each open solid as its own node and entity namespace (two same-shape
  solids stay two), a Part is one node, nothing to draw is an error;
- notebook: one open solid is the implicit Part; several raise SeveralOpenBodiesError naming them and saying
  what to do; the tool-cut-into-body example of the design leaves one open solid; a Part made of the chosen
  solid is the product, its package holds one solid (profile 2, no solid_cache.bodies) and exports one STEP
  solid, and the cell cache restores it; the live preview shows the open solids (OpenBodies);
- split cuts still give Pieces (they are notebook values): the old Pieces tests are kept, and a Part takes
  one piece, not several.

test_body_appearance.py: the appearance tests of a single body (colour, opacity, name, reset, material kept,
pickle, replay, preview, legacy scene compiler, package scene with ONE node and no child solid nodes, STEP
colour). The multi-body definition test is gone with profile 3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
DECISION: only wording. ops_projection.cpp's loop over Part::bodies() stays (it has one element and the loop
shared the code with the appearance carrier); its comment said "every body of a multi-body part". The
on_split comment in the Python boolean test said the pieces of a split cut are "multi-body parts".

WHY: a Part holds exactly one solid since "part: a Part holds exactly one solid" in this series (the owner's
decision after the discussion recorded in design-docs/part-holds-one-solid.md: several solids side by side are
notebook values, not a Part). A comment that says otherwise sends the next reader to the withdrawn design.
No code changes, so the module that was built and verified is unaffected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…that still said "multi-body part"

DECISION: delete the one piece of dead code the one-solid change left behind, and correct comments that point
at the withdrawn design.

WHY: a Part holds exactly one solid (owner's decision after the discussion recorded in
design-docs/part-holds-one-solid.md). The part-definition no longer has `bodies`, so product/packages.py's loop
`for body in getattr(definition, "bodies", ()) or ()` (checking each body's BRep and snapshot path prefix)
could never run; it only hid, by returning (), that the attribute is gone. Removing it with _checked_body_refs
makes the package's blob check say exactly what a definition has.

The comments in scene/preview.py (entity ids `entity/<id>/body:<name>/...`) and _glue/session.py described a
"body of a multi-body part"; `body:<name>` entities now belong to the open solids of a notebook
(compile_open_bodies), and the walk of product values lists a Part's children in _children. No behaviour change.

Checked: test_product_packages, test_scene_preview and test_one_solid_part pass (59 tests).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ies=`, multi-body wording and inspect_body_overlaps_rlist)

DECISION: the generated-style API reference pages follow the code decision of the commit "part: a Part holds
exactly one solid (the constructor refuses several; make_part_rpart and Part lose `bodies=`)".

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The editor must let several solids exist in one notebook with no assembly constraint (pattern a tool, then cut:
for a moment there are several tools and the body). The multi-body Part was made to hold them; the owner judged
that a wrong decision because it makes assembly hard. The agreed analysis: those solids are intermediate values
of a notebook, not product items, so a Part is one solid and the editor shows loose solids. These pages are what
a model author (and the skill) reads, so they must not keep promising `bodies=`, a multi-body Part, per-body
overlap checks or "connectors are not supported on multi-body parts yet".

WHAT CHANGES
- make_part_rpart.md, Part.md: signature `make_part_rpart(part_id, body, name=None)` / `Part(part_id, body, name,
  material, connectors)`; body is a Solid, a Body, or a list of exactly one piece; several solids raise
  ValueError that says what to do (one Part per solid in an Assembly, or union / cut to one solid);
  Part.bodies is a one-element tuple (Body carries display name, colour, opacity).
- set_body_appearance_rpart.md: it paints the Part's one body (body id "body"); the read-back is
  part.bodies[0].
- cut_rsolid.md: pieces of a splitting cut are a notebook value; more than one piece cannot be one Part.
- ref.face.md: "a multi-body result has one per body" -> an operation that makes several solids.
- core/compound.md: a Part is one Solid; part_shape is gone; a Compound is not a way to hold several solids as one
  Part.
- core/part_assembly_development_plan.md: "multi-body part authoring" removed from the out-of-scope list (it is
  not a planned lake any more).
- README.md (API index) and inspect_body_overlaps_rlist.md: the operation was removed (commit "api: remove the
  helpers that only existed for a Part of several bodies"), so its page and index entry are deleted.

NOT REGENERATED: tools/auto_docs_gen.py builds these pages from the docstrings in src/. Those docstrings (the
C++ bindings' make_part_rpart / Part text and the cut_rsolid, ref and set_body_appearance_rpart docstrings) are
owned by the code side of the series; until they say the same, a regeneration would bring the old text back, so
the pages were edited by hand to the same words.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ne-solid Part (and the open-solids rule)

DECISION: the skill pages that tell a model author how to structure a part, a notebook and an editor session say
what the SDK now does: a Part holds exactly one solid; several solids that exist together while building are
notebook values; a part notebook with one open solid has an implicit Part, with several it has no product.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The multi-body Part was made so that the editor could hold several solids in one notebook with no assembly
constraint (pattern a tool, then cut). The owner judged it a wrong decision because it makes assembly hard; the
agreed analysis was that those solids are intermediate values of a notebook, not product items, so a Part is one
solid and the editor shows loose solids. The skill is what an agent reads before it writes a model: if it kept
recommending `make_part_rpart(bodies={...})` or "the runtime makes the Part from all open bodies", agents would
write code that now fails (or, worse, rely on a rule that no longer exists). The pages also have to tell the
author what to do instead, because the old advice was "keep them as bodies of the part".

WHAT CHANGES
- domains/part-modeling.md: "Multi-body parts" becomes "One Part, one solid" (refusal and its remedy: one Part per
  solid in an Assembly, or union / cut; part.body / part.bodies; the open-solids rule and the editor's "N open
  solids" / use_as_product; mass). The split-cut paragraph says the pieces are notebook values and several pieces
  cannot be one Part. The colour section uses set_body_appearance_rpart(part, color=...) (body id "body") and
  part.bodies[0] for the read-back.
- domains/assembly-and-product.md: a Part wraps one Solid; every Part can be placed, mated and given connectors;
  the "several bodies of one part" entry in "Do not use" is removed (there is no such thing).
- discipline/notebook-cell-design.md C9: one open solid -> implicit Part; several -> SeveralOpenBodiesError, choose
  the solid that is the part.
- discipline/mechanical-modeling.md, discipline/failure-and-repair.md ("Union returns multiple bodies"): the answer
  for "several bodies" is an Assembly of Parts, not a multi-body Part.
- docs/guides/notebook-runtime.md: the implicit-Part rule and the "A Part holds exactly one solid" bullet; the part
  definition is always profile simplecad-part-definition-2 with one solid.
- domains/editor-gui-features.md: "New body" leaves a loose solid beside the first ("N open solids"); use_as_product
  makes the Part from the chosen one.
- workflows/single-part-modeling/role-4-build.md: a split cut's pieces are one Part each in an Assembly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… solid_cache.bodies, no profile 3)

DECISION: the package-format reference states the part definition as it is again: profile
simplecad-part-definition-2, one solid, at most one appearance row.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The multi-body Part existed so the editor could keep several solids side by side with no assembly constraint
(pattern a tool, then cut); the owner judged it a wrong decision that makes assembly hard, and the agreed
analysis was that those solids are notebook intermediates, not product items, so a Part is one solid. The commit
"artifacts: a part definition holds one solid (drop BodyRef, `solid_cache.bodies` and profile 3)" removed the
durable format that described several bodies. This page is what a reader of a package follows, so it must not
tell them to expect `solid_cache.bodies` or a "multi-body part names its bodies" rule.

- docs/skill/references/scadpkg-format.md ("Definitions"): names the profile, the one-row appearance block
  (colour and opacity, name), and says explicitly that `solid_cache.bodies` and profile 3 do not exist (they
  only appeared in the withdrawn 2.2.8 release), so a reader who met them there knows they are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d what the multi-body notes now mean)

DECISION: say the change where a release reader and a maintainer look: the Unreleased changelog entry, and the
status lines of the defect log entries that were written for the multi-body research.

WHY - THE DISCUSSION (owner and assistant, 2026-11-10; full account in design-docs/part-holds-one-solid.md)
The editor needs several solids in one notebook with no assembly constraint (pattern a tool, then cut). The
multi-body Part was made for that and the owner judged it a wrong decision because it makes assembly hard; the
agreed analysis: those solids are notebook intermediates, not product items, so a Part is one solid and the editor
shows loose solids. The 2.2.8 release carrying the multi-body Part was withdrawn on 2026-11-10 after an audit
(connectors refused, explode_rassembly raised, the legacy scene compiler read part.body), so the next release's
notes must say that the feature is gone and what replaces it, and the defect log must not keep saying "worked
around by bodies".

- CHANGELOG.md, Unreleased / Changed: removed API (bodies=, Part.multi_body, part_shape,
  inspect_body_overlaps_rlist, BodyOverlapWarning, profile 3 / solid_cache.bodies), the reason, the open-solids
  rule (one open solid -> implicit Part; several -> SeveralOpenBodiesError; editor "N open solids" and
  use_as_product) and the remedy for deliverables of several solids (an Assembly of Parts).
- examples/SDK_DEFECTS.md entries 27-30 (marked [multi-solid research]): status lines updated: 27 (Compound drops
  tags) is still open but no product value is affected; 28 (pickling) names the new Part reader; 29 ("one solid per
  definition") is resolved, the `body:` id segment now only names the open solids of a preview; 30 (error guidance
  frozen by goldens): the guidance sentence is right again.

README.md and README.zh-CN.md were searched and say nothing about multi-body Parts, so they are unchanged.
sandbox/arm6 is untracked scratch work outside this worktree; it calls no `bodies=` (one comment mentions a
BodyOverlapWarning observation) and was left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nd the pages are regenerated from them)

DECISION: update the docstrings that are the source of the generated API reference (and of help() in a
notebook), then regenerate the pages with tools/auto_docs_gen.py, instead of leaving hand-edited pages that the
next regeneration would overwrite with the old multi-body text.

WHY: the docs commits of this series (cherry-picked from a parallel docs pass) were edited by hand because the
generator reads src/. They were right, but the sources they derive from still said "several named bodies",
`bodies={...}`, "connectors are not supported on multi-body parts yet" and "paints the lid body of a multi-body
part". The owner's decision (design-docs/part-holds-one-solid.md: the editor's several solids are notebook
values, a Part is one solid, several solids are an Assembly of Parts) has to hold in the text a user reads at
help(scad.make_part_rpart) too, and the generated pages must equal their sources or the next doc run undoes the
change.

- _glue/docstrings.json (read back over the Python / binding docstrings at import): make_part_rpart, Part, Body,
  set_body_appearance_rpart, cut_rsolid (pieces are notebook values; one Part per piece) and apply_tag_rselection
  (the body of a Part). measure_part_mass_rmassreport's source was already updated by "api: remove the helpers
  that only existed for a Part of several bodies"; its page is regenerated here.
- ref.py: ref.face's `slot` text said "a multi-body result has one per body"; it is "an operation that makes
  several solids has one per solid" (what the docs pass wrote by hand), so the source and the page agree.
- docs/skill/references/docs/api: Body, set_body_appearance_rpart, cut_rsolid, apply_tag_rselection and
  measure_part_mass_rmassreport regenerated (LF endings; the generator writes CRLF on Windows and the committed
  pages are LF). Checked: tools/auto_docs_gen.py's whole output (415 pages) equals the committed directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… piece

DECISION: the notebook in test_cell_cache_store.py::TestPiecesComeBackAsPieces made `make_part_rpart(body=moved)`
from the pieces of a split cut, which was a multi-body Part with bodies body_1 / body_2. A Part holds exactly
one solid now (design-docs/part-holds-one-solid.md), so the last cell makes the Part from one piece.

WHY: the test is about the cell cache restoring `Pieces` as `Pieces` (marimo stores a list as a plain list), not
about multi-body Parts, so what it protects is kept: the restored split still feeds the next cell as pieces
(kind == "Pieces", the translate runs per piece). Only the product cell and the final assertion changed: the
Part is `moved[0]`, and the assertion compares the Part's body (id and the moved height: +1 mm between the
first and the second run) instead of the number of `definition.bodies`, which no longer exists.

Found by running the test files that mention bodies / Pieces after the one-solid change (178 other tests in
those files passed). Checked: test_cell_cache_store.py passes (9 tests).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… `bodies`

DECISION: update test_editor.py::ApiIndexTest::test_classes_list_their_constructor_without_private_fields to the
one-solid constructor: `part_id` and `body` are required, `bodies` is not a parameter.

WHY: the test said "a Part takes one body or several bodies (multi-body parts), so only part_id is required". A
Part holds exactly one solid since "part: a Part holds exactly one solid" in this series (owner's decision, see
design-docs/part-holds-one-solid.md), so `body` is required and `bodies` must not appear in the editor's index of
the SDK constructor signatures (the index is what the editor's forms and completion read). An earlier commit of
the series ("editor: several open solids have no product ...") fixed the first assertion of this test but left
this one, which a full run of the suite caught (the earlier run of the editor test files had passed it only
because the test then still matched the stale index).

Checked: test_editor.py::ApiIndexTest passes (6 tests).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…it, with the reasons from the discussion)

DECISION: record the owner's commit rule in AGENT.md, so that it applies to every later session and not only to
the series that introduced it.

WHY: while the one-solid Part was being implemented (design-docs/part-holds-one-solid.md) the owner asked for
every decision to be its own commit, split as finely as possible, with a message that states the chain of the
decision and its reason, and said that the reason is essentially the discussion between the owner and the
assistant, because people who take over or trace back later must be able to see from the commit message why the
decision exists. The owner then asked for this to be written into AGENT.md, which is the file that tells an
agent how to work in this repository.

The section states the rule and its purpose, what counts as a decision, the message layout that the series used
(DECISION / WHY / ALTERNATIVE REJECTED / WHAT CHANGES / Checked), the decision record in design-docs (with
`git add -f`), the practice of naming other commits by subject because hashes changed once when the series'
messages were reworded, the duty to run a check before claiming it, and the commit mechanics that worked in this
environment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
….1.8 (CI's clang-format job failed on them)

DECISION: only formatting, no behaviour change: re-wrap the header comment of part.hpp and the `bodies` property
definition in bind_product_values.cpp the way clang-format 18.1.8 writes them.

WHY: the CI job "clang-format" of the pull request for this series ("A Part holds exactly one solid") listed
exactly these two files ("needs formatting"). They were written by hand in the commit "part: a Part holds exactly
one solid" and the comment commit that followed, and the local build does not run clang-format (the project pins
18.1.8, installed here with `uv tool run --from clang-format==18.1.8 clang-format`). The reason for those edits is
unchanged (design-docs/part-holds-one-solid.md: a Part holds exactly one solid); this commit only makes them pass
the repository's format gate.

Checked: the eight C++ files the series touched are identical to clang-format 18.1.8's output (compared with
the CR characters removed). Note: running src/cpp/tools/check_format.sh with CLANG_FORMAT="uv tool run ..." reports
every file, because the script quotes the variable as one command name; use an installed clang-format instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…(Python 3.12 CI failed on it)

DECISION: the open solids a notebook leaves are compared as a set of names in the tests, and the docstring of
SeveralOpenBodiesError says the order is the cell order only across cells.

WHY: CI (Python 3.12) failed test_one_solid_part.py::TestNotebookProduct::test_several_open_solids_have_no_product
with `('tool', 'base') != ('base', 'tool')`. Both solids are defined by one cell, and the names a cell defines
come from a set, so their order is not fixed (it differed on 3.12; it can differ on any run). The behaviour that
matters (design-docs/part-holds-one-solid.md: several open solids give no product, and the error names them and
says what to do) does not depend on that order, and the editor's own list (`snapshot()["open"]`) is built from the
cell order and stays deterministic. So the test asserted more than the rule: it now asserts the set of names, in
this test and in the same assertion of test_editor_features.py (which passed locally by luck).

- runtime/projection.py: SeveralOpenBodiesError's docstring no longer says the names come "in cell order"
  without qualification.

Checked: test_one_solid_part.py passes (28 tests). I did not make the order deterministic in the code: nothing
needs it, and the owner's rule is to do only what is necessary.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@NiJingzhe
NiJingzhe merged commit e01016c into dev Nov 9, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant