From 654a6d3e3363f768c98fe4ca4540da55a6bba32a Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:48:34 +0800
Subject: [PATCH 01/23] part: a Part holds exactly one solid (the constructor
refuses several; 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
(b607c852, 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
) 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) 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
---
src/cpp/bindings/python/bind_product.cpp | 31 +---
src/cpp/bindings/python/bind_product_args.hpp | 41 ++---
.../bindings/python/bind_product_values.cpp | 31 ++--
src/cpp/include/scad/product/operators.hpp | 2 -
src/cpp/include/scad/product/part.hpp | 18 +--
src/cpp/src/product/ops_structure.cpp | 30 ----
src/cpp/src/product/part.cpp | 18 ++-
.../contracts/part-definition-3.schema.json | 152 ------------------
8 files changed, 48 insertions(+), 275 deletions(-)
delete mode 100644 src/simplecadapi/contracts/part-definition-3.schema.json
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..2f001669 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,24 @@ 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/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..28025414 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/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/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"}]}}
- }
- }
-}
From 2c998266b60b3aa03a5248d9b654843360f3e356 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:48:51 +0800
Subject: [PATCH 02/23] python glue + replay: follow the one-solid Part (pickle
through Body, refuse a recorded multi-solid make_part_rpart)
DECISION: the Python value layer and the model-JSON replay follow the rule of 654a6d3e (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 654a6d3e): 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
---
src/simplecadapi/_glue/session.py | 3 +--
src/simplecadapi/_glue/values.py | 13 +++++--------
src/simplecadapi/recording/serializer.py | 23 ++++++-----------------
3 files changed, 12 insertions(+), 27 deletions(-)
diff --git a/src/simplecadapi/_glue/session.py b/src/simplecadapi/_glue/session.py
index 7b0fa38b..7d5cd9d3 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
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/recording/serializer.py b/src/simplecadapi/recording/serializer.py
index b29e6adb..79979014 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,
@@ -3449,23 +3448,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]),
From 9fb9401fac4e14bfb53340b6093c9ee8e365b32a Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:48:51 +0800
Subject: [PATCH 03/23] artifacts: a part definition holds one solid (drop
BodyRef, `solid_cache.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 (b607c852, 9778a8e0) is removed together with the Part rule of
654a6d3e.
WHY (the discussion in short; the full account is in 654a6d3e): 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
---
src/simplecadapi/artifacts/assembly_io.py | 10 +-
src/simplecadapi/artifacts/canonical.py | 3 -
src/simplecadapi/artifacts/part_definition.py | 126 +++---------------
src/simplecadapi/artifacts/part_io.py | 37 +----
src/simplecadapi/artifacts/validation.py | 11 +-
src/simplecadapi/build/definition.py | 52 +-------
6 files changed, 42 insertions(+), 197 deletions(-)
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/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..e9e6a527 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
From 6c49c88ba7718d91dae1f07141cc5b16ccc9c970 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:51:18 +0800
Subject: [PATCH 04/23] design-docs: record the decision that a Part holds
exactly one solid (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
---
design-docs/multi-solid-design.md | 4 ++
design-docs/part-holds-one-solid.md | 98 +++++++++++++++++++++++++++++
2 files changed, 102 insertions(+)
create mode 100644 design-docs/part-holds-one-solid.md
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.
From 90fe84ad38303610b801e58acf8e0a31cb5bc12d Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:51:52 +0800
Subject: [PATCH 05/23] runtime + preview: several open solids are shown side
by side, but are 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//body:`, entity
namespace `entity//body:/...` (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
---
src/simplecadapi/runtime/live.py | 38 ++++++++-
src/simplecadapi/runtime/projection.py | 90 ++++++++++++++++++---
src/simplecadapi/scene/preview.py | 107 +++++++++++++++----------
3 files changed, 177 insertions(+), 58 deletions(-)
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..9ea59bd7 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,29 @@ 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, in cell 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 +162,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 +222,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 +297,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 +333,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/scene/preview.py b/src/simplecadapi/scene/preview.py
index d22afc4f..edaa6e2c 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)
From 2029beedeadfd68b5bca5f202405d19bd35064df Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:53:50 +0800
Subject: [PATCH 06/23] scene + exporters: a Part is drawn, exported and
measured from its one 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//body:` 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
---
src/simplecadapi/build/definition.py | 2 +-
src/simplecadapi/exporter/_surface_mesh.py | 2 +-
src/simplecadapi/exporter/mjcf.py | 3 +-
src/simplecadapi/exporter/step.py | 24 +-----
src/simplecadapi/scene/prewarm.py | 2 +-
src/simplecadapi/scene/product_scene.py | 94 +++-------------------
6 files changed, 19 insertions(+), 108 deletions(-)
diff --git a/src/simplecadapi/build/definition.py b/src/simplecadapi/build/definition.py
index e9e6a527..33ffea7d 100644
--- a/src/simplecadapi/build/definition.py
+++ b/src/simplecadapi/build/definition.py
@@ -255,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/exporter/_surface_mesh.py b/src/simplecadapi/exporter/_surface_mesh.py
index 1eaaede3..9d27c4c3 100644
--- a/src/simplecadapi/exporter/_surface_mesh.py
+++ b/src/simplecadapi/exporter/_surface_mesh.py
@@ -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:
diff --git a/src/simplecadapi/exporter/mjcf.py b/src/simplecadapi/exporter/mjcf.py
index be6cd0f9..455df56f 100644
--- a/src/simplecadapi/exporter/mjcf.py
+++ b/src/simplecadapi/exporter/mjcf.py
@@ -18,7 +18,6 @@
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,
@@ -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/step.py b/src/simplecadapi/exporter/step.py
index 231891f0..d8a823a7 100644
--- a/src/simplecadapi/exporter/step.py
+++ b/src/simplecadapi/exporter/step.py
@@ -12,7 +12,6 @@
from ..artifacts import materialize_definition
from simplecadapi._cpp import Assembly, Part
-from ..product.bodies import part_shape
from ..translator.package_units import (
ProductPackageInput,
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 = {
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
From 84aab45d96264a51db678b77d399e44b2f6f9ab6 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 03:55:34 +0800
Subject: [PATCH 07/23] api: remove the helpers that only existed for a Part of
several bodies
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
---
src/simplecadapi/__init__.py | 11 +-
src/simplecadapi/_glue/docstrings.json | 3 +-
src/simplecadapi/_glue/modules.json | 1 -
src/simplecadapi/operators/__init__.py | 3 +-
src/simplecadapi/operators/product.py | 3 +-
src/simplecadapi/product/bodies.py | 143 ++---------------------
src/simplecadapi/recording/serializer.py | 6 +-
7 files changed, 16 insertions(+), 154 deletions(-)
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..a673d288 100644
--- a/src/simplecadapi/_glue/docstrings.json
+++ b/src/simplecadapi/_glue/docstrings.json
@@ -349,8 +349,7 @@
"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.",
+ "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.",
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/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/recording/serializer.py b/src/simplecadapi/recording/serializer.py
index 79979014..7ba05f78 100644
--- a/src/simplecadapi/recording/serializer.py
+++ b/src/simplecadapi/recording/serializer.py
@@ -385,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",
From d415647e965044a5d8fbe6f1a3847f255b8441a6 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:01:35 +0800
Subject: [PATCH 08/23] editor: several open solids have no product until the
author picks one (use_as_product), and say so
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 ` = scad.make_part_rpart(part_id=scad.notebook_id(), body=)`. Before, it
refused ("no product cell"), which would have left the author without a way out.
- editor-web
- FeatureTree: a notice with one "use " 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//body:` 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
---
editor-web/src/components/FeatureTree.tsx | 12 +++++-
editor-web/src/i18n/locales/zh-CN.json | 3 ++
editor-web/src/lib/features.ts | 5 ++-
editor-web/src/lib/preview.test.ts | 5 ++-
editor-web/src/lib/preview.ts | 13 +++---
src/simplecadapi/editor/gui_features.py | 13 ++++--
test/test_editor.py | 2 +-
test/test_editor_features.py | 50 ++++++++++++++++++++++-
8 files changed, 87 insertions(+), 16 deletions(-)
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..d1b1b565 100644
--- a/editor-web/src/i18n/locales/zh-CN.json
+++ b/editor-web/src/i18n/locales/zh-CN.json
@@ -1205,6 +1205,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/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/test/test_editor.py b/test/test_editor.py
index cbde287a..2eece3cf 100644
--- a/test/test_editor.py
+++ b/test/test_editor.py
@@ -866,7 +866,7 @@ def test_classes_list_their_constructor_without_private_fields(self) -> None:
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"])
+ self.assertEqual([p["n"] for p in part["p"] if p["r"]], ["part_id", "body"])
self.assertIn("body", names)
self.assertIn("bodies", names)
self.assertFalse([n for n in names if n.startswith("_")])
diff --git a/test/test_editor_features.py b/test/test_editor_features.py
index 3f7da784..974d284c 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 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()
From f016a14984b3bc4253b7fdebcf85a52a3f36bb46 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:04:14 +0800
Subject: [PATCH 09/23] editor-web: the "model shown" log says what the header
says (open solids 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
---
editor-web/src/App.tsx | 4 +---
editor-web/src/i18n/locales/zh-CN.json | 3 +--
2 files changed, 2 insertions(+), 5 deletions(-)
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/i18n/locales/zh-CN.json b/editor-web/src/i18n/locales/zh-CN.json
index d1b1b565..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": "平移",
From 910df752605baba9e46ee8d3e758abf693073391 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:07:16 +0800
Subject: [PATCH 10/23] AGENT.md: record how to rebuild the C++ module here,
and why the obvious 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
---
AGENT.md | 26 +++++++++++++++++++
...ti_body_part.py => test_one_solid_part.py} | 0
2 files changed, 26 insertions(+)
rename test/{test_multi_body_part.py => test_one_solid_part.py} (100%)
diff --git a/AGENT.md b/AGENT.md
index c707112d..cb2f7791 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -29,3 +29,29 @@ 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//` (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).
diff --git a/test/test_multi_body_part.py b/test/test_one_solid_part.py
similarity index 100%
rename from test/test_multi_body_part.py
rename to test/test_one_solid_part.py
From 0307aaa40f8d0486186ecde62944ce7bfc8b7c93 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:07:57 +0800
Subject: [PATCH 11/23] tests: replace the multi-body Part tests by tests of
the one-solid rule (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
---
test/test_body_appearance.py | 183 +++++---------
test/test_one_solid_part.py | 455 +++++++++++++++++------------------
2 files changed, 281 insertions(+), 357 deletions(-)
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_one_solid_part.py b/test/test_one_solid_part.py
index 62426415..d6f13763 100644
--- a/test/test_one_solid_part.py
+++ b/test/test_one_solid_part.py
@@ -1,27 +1,24 @@
-"""Multi-body parts: one Part, several named solids kept apart (not an assembly).
+"""A Part holds exactly one solid; several solids side by side are loose notebook values.
-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.
+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 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 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
@@ -34,220 +31,252 @@ 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 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
-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_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_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_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_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_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_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_the_removed_helpers_are_gone(self) -> None:
+ for name in ("part_shape", "inspect_body_overlaps_rlist", "BodyOverlap", "BodyOverlapWarning"):
+ self.assertFalse(hasattr(scad, name), name)
- 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)
+class TestEveryPartCanJoinAnAssembly(unittest.TestCase):
+ """What the multi-body Part could not do (it refused connectors and made explode_rassembly raise)."""
- 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()
+ 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))
)
- 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))
+ 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()),
)
- compound = scad.make_compound_from_assembly_rcompound(assembly=rig)
- self.assertEqual(len(compound.solids()), 2)
+ 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 TestMultiBodyReplay(unittest.TestCase):
- def test_a_recorded_multi_body_part_replays_with_its_bodies(self) -> None:
+class TestReplay(unittest.TestCase):
+ def test_a_recorded_part_replays(self) -> None:
with scad.GraphSession() as session:
- _case()
+ 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], [("base", "lid")])
- self.assertAlmostEqual(replayed[0].get_body("lid").solid.get_volume(), 800.0, places=6)
+ 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
-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())
+ 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/case/body:base", "node/case/body:lid"])
+ 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/case/body:lid/solid/"))
- self.assertTrue(all(g["entity_id"].startswith("entity/case/body:lid/face/") for g in lid["face_groups"]))
+ 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_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_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_one_body_part_previews_as_before(self) -> None:
+ 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"')
-CASE = notebook_text(
+
+TWO_OPEN = _part_notebook(
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")
+ tool = scad.make_cylinder_rsolid(3.0, 30.0, (10.0, 10.0, -5.0))
""",
- returns="base, lid",
+ 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(
- 'case = scad.make_part_rpart(part_id=scad.notebook_id(), bodies={"base": base, "lid": lid})',
- reads="base, lid",
- returns="case",
+ """
+ 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",
),
- table='id = "case"',
+ 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 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"])
+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(caught.exception.names, ("base", "tool"))
+ 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_the_package_holds_both_bodies_with_their_tags(self) -> None:
- definition = run_notebook(self.case).definition
+ 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([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))
+ self.assertEqual(manifest["profile"], "simplecad-part-definition-2")
+ self.assertNotIn("bodies", manifest["solid_cache"])
with tempfile.TemporaryDirectory() as directory:
- path = Path(directory) / "case.step"
- export_product_package_to_step(data, path)
+ 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)), 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, ())
+ 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 TestSplitCutsAndOverlaps(unittest.TestCase):
- """Owner decisions: a splitting cut is not refused; overlapping bodies warn but are allowed."""
+
+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]:
@@ -261,75 +290,21 @@ def test_a_splitting_cut_returns_its_pieces_in_centroid_order(self) -> None:
with self.assertRaisesRegex(Exception, "split the body"):
scad.cut_rsolid(*self._bar_and_slot(), on_split="error")
- def test_pieces_become_bodies(self) -> None:
+ 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)
- 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)
+ 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()
- scad.make_part_rpart(part_id="bar", body=scad.cut_rsolid(bar, 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.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")
+ self.assertLess(part.body.get_center().x, 0.0)
if __name__ == "__main__":
@@ -369,7 +344,9 @@ def test_an_operation_on_the_pieces_of_a_split_body_applies_to_every_piece():
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"]
+ # 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)
From 7e2e99682efce46ee97a6b73dc232c1711c40c43 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:08:07 +0800
Subject: [PATCH 12/23] cpp: comments no longer describe multi-body Parts
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
---
src/cpp/src/product/ops_projection.cpp | 2 +-
src/cpp/tests/python/test_ops_features_booleans.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
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/tests/python/test_ops_features_booleans.py b/src/cpp/tests/python/test_ops_features_booleans.py
index 81527eac..8a5face3 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
+ # 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"]
assert parameter_names(scad.boolean_rsolidlist) == ["left", "right", "operation"]
From ba19f430ad65b10676b093b2b9fc967d7d17a6fa Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:09:08 +0800
Subject: [PATCH 13/23] cleanup: remove the package's per-body blob path check,
and comments 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//body:/...`) and _glue/session.py described a
"body of a multi-body part"; `body:` 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
---
src/simplecadapi/_glue/session.py | 3 +--
src/simplecadapi/product/packages.py | 10 ----------
src/simplecadapi/scene/preview.py | 10 +++++-----
3 files changed, 6 insertions(+), 17 deletions(-)
diff --git a/src/simplecadapi/_glue/session.py b/src/simplecadapi/_glue/session.py
index 7d5cd9d3..68072723 100644
--- a/src/simplecadapi/_glue/session.py
+++ b/src/simplecadapi/_glue/session.py
@@ -89,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/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/scene/preview.py b/src/simplecadapi/scene/preview.py
index edaa6e2c..53f6b68a 100644
--- a/src/simplecadapi/scene/preview.py
+++ b/src/simplecadapi/scene/preview.py
@@ -426,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(
{
@@ -521,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("/")
From 6441107ed36eaa82622d38c748f8322e71763bbe Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:06:25 +0800
Subject: [PATCH 14/23] docs/api: the API pages say a Part holds exactly one
solid (drop `bodies=`, 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
---
docs/skill/references/docs/api/Part.md | 16 +++----
docs/skill/references/docs/api/README.md | 1 -
docs/skill/references/docs/api/cut_rsolid.md | 5 ++-
.../docs/api/inspect_body_overlaps_rlist.md | 43 -------------------
.../references/docs/api/make_part_rpart.md | 30 +++++--------
docs/skill/references/docs/api/ref.face.md | 2 +-
.../docs/api/set_body_appearance_rpart.md | 13 +++---
docs/skill/references/docs/core/compound.md | 7 ++-
.../core/part_assembly_development_plan.md | 2 +-
9 files changed, 33 insertions(+), 86 deletions(-)
delete mode 100644 docs/skill/references/docs/api/inspect_body_overlaps_rlist.md
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/cut_rsolid.md b/docs/skill/references/docs/api/cut_rsolid.md
index 0c63a0d7..52b23ba1 100644
--- a/docs/skill/references/docs/api/cut_rsolid.md
+++ b/docs/skill/references/docs/api/cut_rsolid.md
@@ -52,8 +52,9 @@ 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. A Part holds one solid, so
+``make_part_rpart(part_id=..., body=pieces)`` with several pieces is an error: make one
+Part per piece and put them in an Assembly, or union the pieces if they are one body.
## Raises
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/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/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..ae2da8b4 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,9 +22,8 @@ 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
+``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.
@@ -32,11 +31,11 @@ color=(0.2, 0.4, 0.8))`` paints a one-body part. Recorded and replayed like
### 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 that body's appearance changed; ``part.bodies[0].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/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.
From fb50a97b07d5db79e22bb120f1e16701f9a35dde Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:07:37 +0800
Subject: [PATCH 15/23] docs/skill: domain, discipline, workflow and guide
pages follow the one-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
---
.../discipline/failure-and-repair.md | 5 +-
.../discipline/mechanical-modeling.md | 7 +-
.../discipline/notebook-cell-design.md | 21 +++---
.../docs/guides/notebook-runtime.md | 24 ++++---
.../domains/assembly-and-product.md | 11 ++-
.../references/domains/editor-gui-features.md | 9 +--
.../skill/references/domains/part-modeling.md | 70 +++++++++----------
.../single-part-modeling/role-4-build.md | 4 +-
8 files changed, 77 insertions(+), 74 deletions(-)
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/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/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/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.
From 01852ed1b95951bf051fc2d3265f1ebad702a813 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:07:54 +0800
Subject: [PATCH 16/23] docs/skill: scadpkg-format says a part definition holds
one solid (no 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
---
docs/skill/references/scadpkg-format.md | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
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
From d0040a7a7b443520ee70398949a5579e5508e9fe Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:08:51 +0800
Subject: [PATCH 17/23] changelog + SDK_DEFECTS: record that a Part holds one
solid again (and 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
---
CHANGELOG.md | 10 ++++++++++
examples/SDK_DEFECTS.md | 20 +++++++++++---------
2 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aac885dd..9cf95d8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
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.
From a0923a65f4fb923fc96aa6077a9a493068566a15 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:10:51 +0800
Subject: [PATCH 18/23] docstrings: the source of the API pages say a Part
holds one solid (and 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
---
docs/skill/references/docs/api/Body.md | 4 ++--
.../references/docs/api/apply_tag_rselection.md | 2 +-
docs/skill/references/docs/api/cut_rsolid.md | 5 ++---
.../docs/api/measure_part_mass_rmassreport.md | 15 +++++++--------
.../docs/api/set_body_appearance_rpart.md | 6 +++---
src/simplecadapi/_glue/docstrings.json | 12 ++++++------
src/simplecadapi/ref.py | 2 +-
7 files changed, 22 insertions(+), 24 deletions(-)
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/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 52b23ba1..d315f164 100644
--- a/docs/skill/references/docs/api/cut_rsolid.md
+++ b/docs/skill/references/docs/api/cut_rsolid.md
@@ -52,9 +52,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. A Part holds one solid, so
-``make_part_rpart(part_id=..., body=pieces)`` with several pieces is an error: make one
-Part per piece and put them in an Assembly, or union the pieces if they are one body.
+``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/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/set_body_appearance_rpart.md b/docs/skill/references/docs/api/set_body_appearance_rpart.md
index ae2da8b4..10708b10 100644
--- a/docs/skill/references/docs/api/set_body_appearance_rpart.md
+++ b/docs/skill/references/docs/api/set_body_appearance_rpart.md
@@ -23,8 +23,8 @@ overrides the material colour for display (the live preview, the package scene,
surface colour of that solid). Mass, density and geometry never read 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
+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
@@ -55,7 +55,7 @@ paints the part's body (a Part holds one solid, whose body id is ``"body"``). Re
## Returns
-Part: The Part with that body's appearance changed; ``part.bodies[0].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/src/simplecadapi/_glue/docstrings.json b/src/simplecadapi/_glue/docstrings.json
index a673d288..8024dc76 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 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.",
@@ -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.",
@@ -348,7 +348,7 @@
"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``.",
+ "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.",
@@ -368,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/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.
From b56634ee6ec055673725323ff8ff6e769da2eaee Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:14:05 +0800
Subject: [PATCH 19/23] tests: the cell-cache test of restored Pieces makes its
Part from one 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
---
test/test_cell_cache_store.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
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)
From f60f69846d24ef0e1582c44f3bd538bf1e2e6e9b Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:17:28 +0800
Subject: [PATCH 20/23] tests: the editor's API index test expects Part to take
`body` and no `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
---
test/test_editor.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/test/test_editor.py b/test/test_editor.py
index 2eece3cf..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
+ # 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:
From 0a961d14ecc0998c640813e056368ef1bfcf2603 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:19:36 +0800
Subject: [PATCH 21/23] AGENT.md: write down how commits are organised (one
decision per commit, 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
---
AGENT.md | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/AGENT.md b/AGENT.md
index cb2f7791..164aa4c5 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -55,3 +55,40 @@ failed three times before it worked. Each failure had a cause that is not visibl
- `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.
From 695a03a04b8d712e694a091f6a225e20228508b4 Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:37:09 +0800
Subject: [PATCH 22/23] cpp: format part.hpp and bind_product_values.cpp with
clang-format 18.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
---
src/cpp/bindings/python/bind_product_values.cpp | 5 +++--
src/cpp/include/scad/product/part.hpp | 8 ++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/cpp/bindings/python/bind_product_values.cpp b/src/cpp/bindings/python/bind_product_values.cpp
index 2f001669..eb29db0d 100644
--- a/src/cpp/bindings/python/bind_product_values.cpp
+++ b/src/cpp/bindings/python/bind_product_values.cpp
@@ -477,8 +477,9 @@ void bind_part(py::module_& m) {
}),
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_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")); },
diff --git a/src/cpp/include/scad/product/part.hpp b/src/cpp/include/scad/product/part.hpp
index 28025414..e7c57377 100644
--- a/src/cpp/include/scad/product/part.hpp
+++ b/src/cpp/include/scad/product/part.hpp
@@ -1,10 +1,10 @@
// 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 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`).
+// 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
From 5efd5bb31aac4fb80e0fe2d8fbdcdd52e19a5eee Mon Sep 17 00:00:00 2001
From: Ni Jingzhe <1448976235@qq.com>
Date: Tue, 10 Nov 2026 04:37:09 +0800
Subject: [PATCH 23/23] tests: do not depend on the order of the names a single
cell defines (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
---
src/simplecadapi/runtime/projection.py | 3 ++-
test/test_editor_features.py | 2 +-
test/test_one_solid_part.py | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/simplecadapi/runtime/projection.py b/src/simplecadapi/runtime/projection.py
index 9ea59bd7..302c0cd8 100644
--- a/src/simplecadapi/runtime/projection.py
+++ b/src/simplecadapi/runtime/projection.py
@@ -68,7 +68,8 @@ 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, in cell order; a preview shows them side by side (:class:`OpenBodies`).
+ 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:
diff --git a/test/test_editor_features.py b/test/test_editor_features.py
index 974d284c..b8a39a93 100644
--- a/test/test_editor_features.py
+++ b/test/test_editor_features.py
@@ -596,7 +596,7 @@ def test_several_open_solids_have_no_product_until_the_author_picks_one(tmp_path
try:
run_notebook(path, cache=False)
except SeveralOpenBodiesError as error:
- assert error.names == ("base", "tool") and "exactly one solid" in str(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"})
diff --git a/test/test_one_solid_part.py b/test/test_one_solid_part.py
index d6f13763..04e0128f 100644
--- a/test/test_one_solid_part.py
+++ b/test/test_one_solid_part.py
@@ -219,7 +219,7 @@ def test_one_open_solid_is_the_implicit_part(self) -> None:
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(caught.exception.names, ("base", "tool"))
+ 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)