From 292045d3fa4c4608b2ceadbf36aed5e8d94b16f4 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Tue, 10 Nov 2026 00:52:40 +0800 Subject: [PATCH 01/32] editor: probe the editor pid without signalling it (os.kill(pid, 0) is a POSIX liveness probe; on Windows os.kill maps signal 0 to TerminateProcess, so every sca cell call killed the editor it was talking to and then failed with WinError 87) --- src/simplecadapi/editor/cli.py | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/simplecadapi/editor/cli.py b/src/simplecadapi/editor/cli.py index f0bb3e34..f7c3c977 100644 --- a/src/simplecadapi/editor/cli.py +++ b/src/simplecadapi/editor/cli.py @@ -453,6 +453,40 @@ def _opener() -> Any: return urllib.request.build_opener(urllib.request.ProxyHandler({})) +def _pid_alive(pid: int) -> bool: + """Probe a pid without signalling it. + + ``os.kill(pid, 0)`` is a POSIX liveness probe: on Windows ``os.kill`` + maps every signal except the two CTRL events to ``TerminateProcess``, + so probing the editor's pid that way kills the editor. + """ + import os + + if os.name == "nt": + import ctypes + + process_query_limited_information = 0x1000 + still_active = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(process_query_limited_information, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + return code.value == still_active + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + pass # a live process we cannot signal + return True + + def _find_editor(start: Path) -> tuple[Path, str] | None: """The running editor whose workspace holds *start*: ``(workspace, base url)``. @@ -479,16 +513,11 @@ def _find_editor(start: Path) -> tuple[Path, str] | None: unreachable.append(f"{record}: unreadable ({error})") continue pid = body.get("pid") - if isinstance(pid, int): - try: - os.kill(pid, 0) - except ProcessLookupError: - unreachable.append( - f"stale record {record} (pid {pid} is gone); delete it or restart the editor" - ) - continue - except OSError: - pass # a live process we cannot signal + if isinstance(pid, int) and not _pid_alive(pid): + unreachable.append( + f"stale record {record} (pid {pid} is gone); delete it or restart the editor" + ) + continue try: with opener.open(f"{url}/api/health", timeout=1.5): pass 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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 09/32] 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 10/32] 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 11/32] 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 12/32] 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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 19/32] 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 20/32] 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 21/32] 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 22/32] 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 23/32] 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 24/32] 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) From d88289e0ef79888dad9aa4f53197d87733fc776b Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Tue, 10 Nov 2026 05:27:04 +0800 Subject: [PATCH 25/32] release: 2.2.9 (replaces the withdrawn 2.2.8) DECISION: release the one-solid Part and the fixes on dev as 2.2.9. The version number is 2.2.9, not 2.2.8 again and not 2.2.8.post1. WHY - THE DISCUSSION (owner and assistant, 2026-11-10) - 2.2.8 shipped a Part that could hold several bodies. The audit found such a Part could not take part in an assembly (no connectors, explode_rassembly raised); the owner judged the multi-body Part a wrong decision made for the editor's convenience, and the agreed answer was Part = one solid, the editor shows loose solids (design-docs/part-holds-one-solid.md, merged in the pull request "A Part holds exactly one solid"). - The owner deleted the GitHub release of 2.2.8 and yanked 2.2.8 on PyPI. The owner asked whether 2.2.8 could be published again: it cannot. PyPI keeps a version and its file names for ever, even when yanked or deleted, and does not accept another upload under them. So the fixed code needs a new number. - 2.2.9 rather than 2.2.8.post1: the change removes interface that 2.2.8 had (`bodies=`, `inspect_body_overlaps_rlist`, `part_shape`, profile 3), which a patch number says more plainly than a post-release of a withdrawn version; this repository's `.post1` / `.post2` releases (2.2.6) were for packaging fixes. WHAT CHANGES (the same files as "release: 2.2.8" touched) - pyproject.toml, uv.lock (the simplecadapi package entry), docs/skill/SKILL.md (version, package-version) and docs/skill/references/SDK_OVERVIEW.md: 2.2.8 -> 2.2.9. - CHANGELOG.md: the Unreleased entry of the one-solid change becomes 2.2.9 (2026-11-10), with a Fixed section for what else is on dev since the 2.2.8 tag: the Windows editor pid probe, GCC 14 and MinGW source builds, macOS wheels on macos-15. - docs/updates/2.2.9.md and 2.2.9.zh-CN.md: the update notes, which the release workflow turns into the release page. They say that 2.2.8 is withdrawn, why a Part holds one solid, what changes for users (the argument and helpers removed, SeveralOpenBodiesError, the editor's open solids) and what was fixed. Checked: nothing in the tests reads these files; the release workflow checks that the tag equals the pyproject version (v2.2.9). The wheels and the PyPI upload are built by the workflow when the tag is pushed. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 +++++- docs/skill/SKILL.md | 4 +-- docs/skill/references/SDK_OVERVIEW.md | 4 +-- docs/updates/2.2.9.md | 50 +++++++++++++++++++++++++++ docs/updates/2.2.9.zh-CN.md | 44 +++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 docs/updates/2.2.9.md create mode 100644 docs/updates/2.2.9.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf95d8b..99366250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to SimpleCADAPI are documented here. Per-version user-facing notes live in [`docs/updates/`](docs/updates/) (English and 中文). -## Unreleased +## 2.2.9 — 2026-11-10 + +Replaces 2.2.8, which was withdrawn (yanked on PyPI). User-facing notes: [`docs/updates/2.2.9.md`](docs/updates/2.2.9.md). ### Changed - A `Part` holds exactly one solid again. The multi-body Part of the withdrawn 2.2.8 release (`make_part_rpart(bodies=...)`, @@ -16,6 +18,12 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and `SeveralOpenBodiesError` naming them. The editor draws them side by side ("N open solids") and its `use_as_product` makes the Part from the one you choose. Several solids as a deliverable are one Part each in an Assembly. +### Fixed +- On Windows `sca cell` no longer kills the editor it talks to: the editor pid is probed without signalling it + (`os.kill(pid, 0)` terminates the process on Windows). +- Building from source with GCC 14 (`operators_promote.cpp` includes ``) and MinGW (`_USE_MATH_DEFINES`; `build_occt.py` + picks Ninja when a MinGW `g++` is the compiler); the macOS wheels are built on `macos-15` (still targeting macOS 14.0). + ## 2.2.8 — 2026-11-09 User-facing notes: [`docs/updates/2.2.8.md`](docs/updates/2.2.8.md). diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 4708a6df..2d26981c 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.8 + version: 2.2.9 package-name: simplecadapi - package-version: 2.2.8 + package-version: 2.2.9 --- # SimpleCAD SDK Skill diff --git a/docs/skill/references/SDK_OVERVIEW.md b/docs/skill/references/SDK_OVERVIEW.md index b9eb6971..bc93c570 100644 --- a/docs/skill/references/SDK_OVERVIEW.md +++ b/docs/skill/references/SDK_OVERVIEW.md @@ -1,8 +1,8 @@ # SDK Overview - Project: `simplecadapi` -- Version: `2.2.8` -- Package distribution: `simplecadapi==2.2.8` +- Version: `2.2.9` +- Package distribution: `simplecadapi==2.2.9` ## What this skill bundles diff --git a/docs/updates/2.2.9.md b/docs/updates/2.2.9.md new file mode 100644 index 00000000..ba8cf453 --- /dev/null +++ b/docs/updates/2.2.9.md @@ -0,0 +1,50 @@ +# 2.2.9 Update Notes + +2.2.9 replaces 2.2.8, which has been withdrawn (yanked on PyPI, its GitHub release deleted). 2.2.8 let a `Part` hold several +bodies, and a Part like that could not take part in an assembly. In 2.2.9 a Part holds exactly one solid again, and the +editor keeps what it needed from the change: several solids can sit side by side in a notebook while a model is built. +Everything else in 2.2.8 is in 2.2.9; the 2.2.8 notes describe it. + +## Why a Part holds one solid + +The editor has to let several solids exist in one notebook with no assembly constraint between them: to pattern a tool and then +cut, there is a moment where more than one tool and the body exist together. In 2.2.8 that was solved inside the Part. But a +Part is the unit an assembly places, mates and gives connectors to, and a Part of several bodies could do none of that: + +* connectors on it were refused, so no mate or public connector could reach it; +* `explode_rassembly` raised `TypeError` on any assembly that held one; +* the older scene compiler read the single body of a Part in four places. + +The solids side by side are intermediate values of a notebook, not product items. So they are notebook values now, and the +Part is one solid. The full account is in `design-docs/part-holds-one-solid.md`; every commit of the change states its own +reason. + +## What changes for you + +* **`make_part_rpart(part_id, body, name=None)`** takes one Solid, a `Body` (a solid with a display name, colour and opacity), or + a list of exactly one piece of a split cut. Several solids raise `ValueError`, which says what to do: one Part per solid in + an Assembly (identity placements when they only sit together), or union / cut them into one solid first. +* **Removed** (they only existed in the withdrawn 2.2.8): the `bodies=` argument, `Part.multi_body`, `scad.part_shape`, + `inspect_body_overlaps_rlist`, `BodyOverlap`, `BodyOverlapWarning`, and the part definition profile + `simplecad-part-definition-3` with `solid_cache.bodies`. A part definition is profile 2 with one solid, as before 2.2.8. + A model JSON recorded with several solids in one Part is refused on replay, with the same guidance. +* **A part notebook with one open solid** (`kind = "part"`, no cell making the Part) still gets the implicit Part. **With several + open solids** there is no product: running it raises `SeveralOpenBodiesError`, which names the solids; make the Part from the + one that is the part (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`), or make a Part of each and an Assembly + of them. +* **The editor** draws such open solids side by side and its header says "N open solids"; `use_as_product` makes the Part + from the solid you choose. One-body Parts, `Pieces` (the result of a splitting cut), connectors, mates and `explode_rassembly` + behave as they did. + +## Fixed + +* On Windows, `sca cell` no longer kills the editor it talks to: the editor's process id is probed without signalling it + (`os.kill(pid, 0)` on Windows terminates the process). +* Building from source: GCC 14 (`operators_promote.cpp` includes ``), MinGW (`_USE_MATH_DEFINES`, and + `build_occt.py` picks Ninja when a MinGW `g++` is the compiler), and the macOS wheels are built on `macos-15` (GitHub no + longer serves `macos-14`; the wheels still target macOS 14.0). + +## For contributors + +* `AGENT.md` records how to rebuild the C++ module on Windows and how commits are organised: one decision per commit, with the + reasons from the discussion that led to it. diff --git a/docs/updates/2.2.9.zh-CN.md b/docs/updates/2.2.9.zh-CN.md new file mode 100644 index 00000000..a0617d58 --- /dev/null +++ b/docs/updates/2.2.9.zh-CN.md @@ -0,0 +1,44 @@ +# 2.2.9 更新说明 + +2.2.9 取代 2.2.8。2.2.8 已被撤回(PyPI 上已 yank,GitHub 上的发布也已删除)。2.2.8 让一个 `Part` 可以持有多个 body, +而这样的 Part 没法参与装配。2.2.9 让 Part 重新只持有一个实体,同时保留编辑器需要的能力:建模过程中,笔记本里可以并排放着 +几个实体。2.2.8 的其余内容都在 2.2.9 里,请看 2.2.8 的更新说明。 + +## 为什么 Part 只持有一个实体 + +编辑器必须允许一个笔记本里同时存在几个实体,而且它们之间不需要装配约束:比如先阵列刀具再切割,总有一刻刀具有好几把, +本体也在。2.2.8 把这件事放进了 Part 里解决。但是 Part 是装配里被摆放、被配合、被挂接口的单位,而持有多个 body 的 Part 这些都做不到: + +* 它不能带连接器,所以任何配合或公开连接器都够不到它; +* 装配里只要有一个这样的 Part,`explode_rassembly` 就报 `TypeError`; +* 旧的场景编译器有四处直接读 Part 的唯一 body。 + +并排的几个实体只是笔记本里的中间值,不是产品里的零件。所以现在它们是笔记本里的普通值,Part 就是一个实体。完整的说明在 +`design-docs/part-holds-one-solid.md`,这次改动的每个提交都写明了各自的原因。 + +## 对你有什么影响 + +* **`make_part_rpart(part_id, body, name=None)`** 接受一个实体、一个 `Body`(带显示名、颜色和不透明度的实体),或者切割分裂后 + 恰好只有一块的列表。给多个实体会报 `ValueError`,并说明该怎么做:每个实体做成一个 Part 放进装配(只是放在一起时用单位位姿), + 或者先把它们合并、切割成一个实体。 +* **已移除**(它们只在被撤回的 2.2.8 里存在过):`bodies=` 参数、`Part.multi_body`、`scad.part_shape`、 + `inspect_body_overlaps_rlist`、`BodyOverlap`、`BodyOverlapWarning`,以及零件定义的 `simplecad-part-definition-3` 版式和 + `solid_cache.bodies`。零件定义回到 2.2.8 之前的第 2 版式,只含一个实体。记录了“一个 Part 里有多个实体”的模型 JSON, + 重放时会被拒绝,并给出同样的提示。 +* **零件笔记本里只有一个开放实体**(`kind = "part"`,没有任何单元格做出 Part)时,仍然自动得到隐式 Part。**有多个开放实体** + 时没有产品:运行会报 `SeveralOpenBodiesError`,并列出这些实体;请用真正作为零件的那个来做 Part + (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`),或者给每个实体各做一个 Part,再放进装配。 +* **编辑器**会把这些开放实体并排画出来,标题栏显示“N 个开放实体”;`use_as_product` 会用你选的那个实体做出 Part。 + 单个实体的 Part、`Pieces`(切割分裂的结果)、连接器、配合和 `explode_rassembly` 的行为都和以前一样。 + +## 修复 + +* 在 Windows 上,`sca cell` 不会再杀掉它正在对话的编辑器:现在探测编辑器进程时不再发信号(Windows 上 + `os.kill(pid, 0)` 会直接终止进程)。 +* 从源码构建:GCC 14(`operators_promote.cpp` 补上 ``)、MinGW(定义 `_USE_MATH_DEFINES`,以及编译器是 MinGW 的 + `g++` 时 `build_occt.py` 选用 Ninja);macOS 的安装包改在 `macos-15` 上构建(GitHub 不再提供 `macos-14`,安装包仍然面向 + macOS 14.0)。 + +## 给贡献者 + +* `AGENT.md` 记录了在 Windows 上如何重新编译 C++ 模块,以及提交该怎么组织:一个决策一个提交,说明里写清楚促成这个决策的讨论和原因。 diff --git a/pyproject.toml b/pyproject.toml index 0d330400..a4b6eff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.8" +version = "2.2.9" description = "A simplified OpenCASCADE-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index e1313337..a597e696 100644 --- a/uv.lock +++ b/uv.lock @@ -2996,7 +2996,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.8" +version = "2.2.9" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 453bad341fa158fcca472c65d1ee1a5751e967f9 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Tue, 10 Nov 2026 17:34:22 +0800 Subject: [PATCH 26/32] build: a plain uv sync builds the C++ module on Windows (toolchain and OCCT detection, rebuild only when src/cpp changes) On a Windows machine without Visual Studio, scikit-build-core left the generator to CMake's default (NMake), the build failed with "'nmake' -? failed", and every contributor had to hand-set CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS. uv also rebuilt the editable install on any pyproject.toml change (a version bump), which is how the failure showed up after the 2.2.9 release. - tools/build_backend/scad_build.py: a PEP 517 backend wrapping scikit-build-core. On Windows it uses Ninja + the gcc on PATH when Visual Studio is absent, finds OCCT (SCAD_OCCT_PREFIX, C:/occt, a per-user cache, or builds it once with tools/build_occt.py) and limits jobs by RAM (g++ ran out of memory under ninja's default). Anything the user or CI sets wins. - [tool.uv] cache-keys: rebuild only when src/cpp changes. Build tooling only: nothing in the published package changes, so no version bump. Co-Authored-By: Claude Sonnet 5.5 --- AGENT.md | 8 +- pyproject.toml | 18 ++++- tools/build_backend/scad_build.py | 129 ++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tools/build_backend/scad_build.py diff --git a/AGENT.md b/AGENT.md index 164aa4c5..538814be 100644 --- a/AGENT.md +++ b/AGENT.md @@ -35,7 +35,13 @@ BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks h 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 +- UPDATE (2026-11-10, later): none of the flags below are needed any more. `pyproject.toml` builds with + `tools/build_backend/scad_build.py`, which on Windows picks Ninja + the gcc on PATH when Visual Studio is absent, finds + OCCT (`SCAD_OCCT_PREFIX`, `C:/occt`, or `%LOCALAPPDATA%/simplecadapi/occt-`, building it there once if missing) and + limits the job count by RAM. A bare `uv sync` / `uv run` works; anything you set yourself (CMAKE_GENERATOR, CC, CXX, + CMAKE_ARGS, CMAKE_BUILD_PARALLEL_LEVEL) still wins. `[tool.uv] cache-keys` makes uv rebuild only when `src/cpp` changes, + not on a version bump. What remains true: a running editor locks the installed `.pyd` (rename it aside, see below). +- (Older note) Use the project's own path, `uv pip install --python .venv/Scripts/python.exe -e . --no-deps -v`. It reuses `src/cpp/build/wheel//` (incremental when the compiler flags did not change). Plain `ninja _native` in that directory fails: its CMakeCache points into a temporary isolated build environment (ninja, pybind11, python) that was deleted when the last build ended, so CMake cannot regenerate. diff --git a/pyproject.toml b/pyproject.toml index a4b6eff0..f8bdcd35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,10 @@ Issues = "https://github.com/PhySpace/SimpleCADAPI/issues" [build-system] # One distribution: the Python package and the compiled C++ module (``simplecadapi._cpp``, src/cpp/) it runs on. requires = ["scikit-build-core>=0.10", "pybind11>=2.13", "ninja>=1.11; platform_system == 'Windows'"] -build-backend = "scikit_build_core.build" +# scad_build (tools/build_backend) is scikit-build-core plus toolchain/OpenCASCADE detection on Windows, so that a plain +# `uv sync` builds without hand-set CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS. +build-backend = "scad_build" +backend-path = ["tools/build_backend"] [tool.scikit-build] minimum-version = "build-system.requires" @@ -92,6 +95,7 @@ build-dir = "src/cpp/build/wheel/{wheel_tag}" wheel.packages = ["src/simplecadapi"] wheel.exclude = ["**/__pycache__/**", "**/*.pyc"] sdist.include = [ + "/tools/build_backend", "/src/simplecadapi/editor/webui", "/skillproj.toml", "/docs/skill", @@ -162,6 +166,18 @@ dev = [ "ty>=0.0.18", ] +[tool.uv] +# `uv run` / `uv sync` rebuild the editable install (the C++ module, minutes of compiling, and a C++ toolchain + OCCT on the +# machine) whenever a cache key changed. uv's default key is pyproject.toml, so a version bump or a dependency edit forced +# a rebuild nobody needed. Rebuild only when the C++ module's inputs change. +cache-keys = [ + { file = "src/cpp/CMakeLists.txt" }, + { file = "src/cpp/cmake/**" }, + { file = "src/cpp/include/**" }, + { file = "src/cpp/src/**" }, + { file = "src/cpp/bindings/**" }, +] + [tool.ty.environment] python-version = "3.10" diff --git a/tools/build_backend/scad_build.py b/tools/build_backend/scad_build.py new file mode 100644 index 00000000..874adf8b --- /dev/null +++ b/tools/build_backend/scad_build.py @@ -0,0 +1,129 @@ +"""PEP 517 build backend: scikit-build-core, plus the toolchain/OCCT detection a plain ``uv sync`` needs. + +Why this exists: the package contains a C++ module (src/cpp) that needs a compiler, a build generator and an OpenCASCADE +install. scikit-build-core leaves all three to CMake's defaults, and on a Windows machine without Visual Studio the default +is the NMake generator, which then fails ("'nmake' -? failed"), and nothing tells CMake where OCCT is. Contributors had to +export CMAKE_GENERATOR / CC / CXX / CMAKE_ARGS by hand. This wrapper does that detection itself and then delegates every hook +to scikit-build-core. Anything the user already set (CMAKE_GENERATOR, CC, CXX, SCAD_OCCT_PREFIX, CMAKE_ARGS with a generator +or an OCCT prefix, CMAKE_BUILD_PARALLEL_LEVEL) is left alone, so CI (cibuildwheel) and Linux/macOS builds behave as before. + +Detection runs only on Windows. Linux and macOS use the system OCCT (or CI's) through CMake's own search. +""" +from __future__ import annotations + +import ctypes +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from scikit_build_core import build as _skbuild + +# Everything scikit-build-core exposes (get_requires_for_*, prepare_metadata_*, build_sdist, ...) passes straight through. +from scikit_build_core.build import * # noqa: F401,F403 + +REPO = Path(__file__).resolve().parents[2] +OCCT_TAG = "V7_9_3" # tools/build_occt.py's DEFAULT_TAG: the kernel generation the SDK ships + + +def _log(msg: str) -> None: + print(f"[scad_build] {msg}", file=sys.stderr, flush=True) + + +def _has_msvc() -> bool: + """True when Visual Studio's C++ toolset is installed (CMake's Visual Studio generator finds it by itself).""" + if shutil.which("cl"): + return True + pf = os.environ.get("ProgramFiles(x86)") + vswhere = Path(pf, "Microsoft Visual Studio", "Installer", "vswhere.exe") if pf else None + if vswhere and vswhere.is_file(): + out = subprocess.run( + [str(vswhere), "-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], + capture_output=True, text=True, check=False, + ).stdout.strip() + return bool(out) + return False + + +def _total_memory_gb() -> float: + class _Status(ctypes.Structure): + _fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong), ("total_phys", ctypes.c_ulonglong), + ("avail_phys", ctypes.c_ulonglong), ("total_page", ctypes.c_ulonglong), + ("avail_page", ctypes.c_ulonglong), ("total_virt", ctypes.c_ulonglong), + ("avail_virt", ctypes.c_ulonglong), ("avail_ext", ctypes.c_ulonglong)] + + status = _Status() + status.length = ctypes.sizeof(_Status) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined] + return status.total_phys / 2**30 + + +def _find_occt() -> Path | None: + """An OCCT install prefix (the directory holding lib/cmake/opencascade), looked up in the usual places.""" + candidates = [os.environ.get("SCAD_OCCT_PREFIX"), "C:/occt", str(_occt_cache_dir())] + for c in candidates: + if c and (Path(c) / "lib" / "cmake" / "opencascade").is_dir(): + return Path(c) + return None + + +def _occt_cache_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / "simplecadapi" / f"occt-{OCCT_TAG}" + + +def _build_occt(prefix: Path) -> None: + _log(f"no OpenCASCADE found; building {OCCT_TAG} into {prefix} (one time, 20-40 minutes, cached for every later build)") + subprocess.run([sys.executable, str(REPO / "src" / "cpp" / "tools" / "build_occt.py"), "--prefix", str(prefix), "--tag", OCCT_TAG], check=True) + + +def _prepare_windows_environment() -> None: + env = os.environ + cmake_args = env.get("CMAKE_ARGS", "") + + # 1. generator + compiler + if not env.get("CMAKE_GENERATOR") and "-G" not in cmake_args and not _has_msvc(): + gxx, gcc, ninja = shutil.which("g++"), shutil.which("gcc"), shutil.which("ninja") + if not (gxx and gcc): + raise RuntimeError( + "Building simplecadapi needs a C++ compiler on Windows: install Visual Studio Build Tools (C++ workload) " + "or a MinGW-w64 gcc (e.g. `scoop install gcc`) and re-run." + ) + # ninja is injected by scikit-build-core from the build requirements when the generator is Ninja + env["CMAKE_GENERATOR"] = "Ninja" + env.setdefault("CC", gcc) + env.setdefault("CXX", gxx) + _log(f"Visual Studio not found; using Ninja + {gxx}" + ("" if ninja else " (ninja from the build requirements)")) + + # 2. OpenCASCADE + if "SCAD_OCCT_PREFIX" not in cmake_args: + prefix = _find_occt() + if prefix is None: + prefix = _occt_cache_dir() + _build_occt(prefix) + # OpenCASCADE_DIR too: CMake's package registry may otherwise offer a stale OCCT left in %TEMP% by an old build + cmake_args += f" -DSCAD_OCCT_PREFIX={prefix.as_posix()} -DOpenCASCADE_DIR={(prefix / 'lib' / 'cmake' / 'opencascade').as_posix()}" + env["CMAKE_ARGS"] = cmake_args.strip() + _log(f"OpenCASCADE: {prefix}") + + # 3. parallelism: one g++ translating OCCT-heavy bindings needs ~2 GB, so a many-core machine with little RAM ran out of + # memory ("cc1plus: out of memory") under ninja's default of cpu_count + 2 jobs + if not env.get("CMAKE_BUILD_PARALLEL_LEVEL"): + jobs = max(1, min(os.cpu_count() or 2, int(_total_memory_gb() // 2.5))) + env["CMAKE_BUILD_PARALLEL_LEVEL"] = str(jobs) + + +def _prepare() -> None: + if sys.platform == "win32": + _prepare_windows_environment() + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_editable(wheel_directory, config_settings=None, metadata_directory=None): + _prepare() + return _skbuild.build_editable(wheel_directory, config_settings, metadata_directory) From 342aae581de7ee17f1fd4b5712f8e9e7bd70bfa3 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 02:55:46 +0800 Subject: [PATCH 27/32] fix(artifacts): fingerprint quantization no longer splits a value that sits on a boundary (#87) DECISION: snap the ratio value/tolerance to a binary grid before rounding it to an integer. Do not pre-round to a fixed number of decimals, do not make the snapshot validator tolerant of off-by-one integers. PROBLEM A body's geometry fingerprint is a hash of every measured value divided by 1e-7 mm and rounded to an integer (`_q`, artifacts/geometry_interface.py). A design value such as -3.34690815 mm is exactly a boundary of that rounding, (k + 0.5) * 1e-7. The value computed one way is a unit in the last place under the boundary, the same value after a BREP round trip is one over it (-3.3469081499999995 against -3.34690815), and `int(round(...))` sends the two to neighbouring integers. The reporter's body had two such edge centers: its fingerprint changed across the readback and `restore_topology_snapshot` raised `geometry_mismatch`. REPRODUCED - On the attachment of the issue (R02 BREP + recorded snapshot, read in this checkout, without running the attachment's own script): the fingerprint after the readback is exactly the issue's B (sha256:d9620b...), and the two edge centers quantize to -33469082 and -66530918, the integers the issue lists after the readback. - A whole solid that changes its fingerprint across a BREP round trip could not be built synthetically: the doubles survive the binary BREP unchanged, the one-ulp difference of the issue comes from how the readback shape is measured (centers are integrated). So the regression test is at the quantizer, with the issue's two value pairs and 20000 random values on a boundary with up to three ulps of noise on each side: before this change 6482 of them split, and both issue pairs failed. WHY THIS FIX - Pre-rounding to a fixed number of decimals either leaves large coordinates (a 1 m part is 1e10 quanta, one ulp is 2e-6 of a quantum) unprotected or moves 1% of all values to a different integer. The grid follows the ratio's magnitude (2**-46 of it, at least 2**-24): a half-integer is exactly on it, noise is well under it, and the window of values that change integer is about 1e-5 of a quantum (test: at most 5 of 50000 ordinary values move). - A tolerant validator (accept an off-by-one integer) would have to be taught every place a label is compared (body, face, edge and vertex labels, the topology match keys, the connector hashes); one deterministic quantizer fixes them all at the source. WHAT THIS DOES NOT FIX - Fingerprints are stored in artifacts. A value that was on a boundary now quantizes (half to even) where it used to fall on either side, so such a stored fingerprint changes once; every other value keeps its integer. The reporter's recorded snapshot (made on the other side of the boundary) still does not restore: it has to be captured again. It could not have been restored before either. - `exporter/_surface_mesh.py` and `inspect/brep/compare.py` round the same way for mesh welding and BREP comparison; they were not part of the report and are left alone. Checked: test_artifact_*, test_product_*, test_brep_region_snapshot, test_topology_getter_contract, test_part_interface, test_assembly_builder, test_native_canonical pass. test_scene_contract (generated types) and test_native_without_ocp fail on the base commit as well (this machine lacks OCP and the pinned generator). Co-Authored-By: Claude Sonnet 5.5 --- .../artifacts/geometry_interface.py | 19 +++++- test/test_geometry_interface_quantization.py | 61 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/test_geometry_interface_quantization.py diff --git a/src/simplecadapi/artifacts/geometry_interface.py b/src/simplecadapi/artifacts/geometry_interface.py index e232658c..0470ddd2 100644 --- a/src/simplecadapi/artifacts/geometry_interface.py +++ b/src/simplecadapi/artifacts/geometry_interface.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from collections import Counter, OrderedDict from typing import Any, Dict, List, Tuple @@ -31,7 +32,23 @@ def _enum_suffix(value: Any, prefix: str) -> str: def _q(value: float, tolerance: float) -> int: - return int(round(float(value) / tolerance)) + """``value`` in units of ``tolerance``, rounded to an integer, the same integer for values that differ by noise. + + A plain ``round(value / tolerance)`` splits a value that sits on a boundary, ``(k + 0.5) * tolerance`` (a design value + such as -3.34690815 mm does): the value computed one way is a unit in the last place under the boundary, the same + value after a BREP round trip is one over it, and the two quantize to neighbouring integers (issue #87: a body's + fingerprint changed and restoring its snapshot raised ``geometry_mismatch``). So the ratio is first snapped to a + binary grid that is far coarser than the noise (about 2**-46 of the ratio, at least 2**-24) and far finer than a + quantum, and a half-integer is exactly representable on it; every value within noise of a boundary snaps onto it and + then rounds, half to even, to one integer. A value farther from a boundary than the grid keeps the integer it had. + """ + scaled = float(value) / tolerance + if not math.isfinite(scaled): + return int(round(scaled)) + grid = math.ldexp(1.0, max(math.frexp(scaled)[1] - 46, -24)) + if grid < 0.5: # beyond 2**46 quanta the noise is itself half a quantum: there is nothing to snap + scaled = round(scaled / grid) * grid + return int(round(scaled)) def _qvec(values: Any, tolerance: float) -> tuple[int, ...]: diff --git a/test/test_geometry_interface_quantization.py b/test/test_geometry_interface_quantization.py new file mode 100644 index 00000000..085a43e5 --- /dev/null +++ b/test/test_geometry_interface_quantization.py @@ -0,0 +1,61 @@ +"""The quantizer behind geometry fingerprints must not split values that differ only by computation noise. + +Issue #87: a body's fingerprint changed across a BREP round trip because two edge centers sat on a quantization boundary, +``(k + 0.5) * 1e-7`` mm, and the readback differed from the original by one or two units in the last place. +""" + +from __future__ import annotations + +import math +import random + +import pytest + +from simplecadapi.artifacts.geometry_interface import _q + +TOL = 1.0e-7 + +# The two edge centers of the reporter's R02 body (issue #87): value before the readback, value after, as recorded in the +# issue. Both pairs sit on a boundary and quantized to different integers (-33469081 / -33469082, -66530919 / -66530918). +ISSUE_PAIRS = [ + (-3.3469081499999995, -3.34690815), + (-6.653091850000001, -6.653091849999999), +] + + +@pytest.mark.parametrize("before, after", ISSUE_PAIRS) +def test_values_from_the_issue_quantize_alike(before: float, after: float) -> None: + assert _q(before, TOL) == _q(after, TOL) + + +def test_noise_around_a_boundary_never_splits_a_value() -> None: + rng = random.Random(87) + split = 0 + for _ in range(20000): + magnitude = 10 ** rng.uniform(-3, 4) # mm: a hair to ten metres + k = int(magnitude / TOL) + exact = (k + 0.5) * TOL * rng.choice((-1, 1)) + a, b = exact, exact + for _ in range(rng.randint(0, 3)): # up to three ulps of computation noise, each side + a = math.nextafter(a, rng.choice((-math.inf, math.inf))) + b = math.nextafter(b, rng.choice((-math.inf, math.inf))) + split += _q(a, TOL) != _q(b, TOL) + assert split == 0 + + +def test_ordinary_values_quantize_as_before() -> None: + """The snap window is far narrower than a quantum, so values away from a boundary keep their old integer. + + Fingerprints are stored in artifacts: a rule that moved many integers would invalidate them for no reason.""" + rng = random.Random(5) + moved = 0 + for _ in range(50000): + value = rng.uniform(-200.0, 200.0) + moved += _q(value, TOL) != int(round(value / TOL)) + assert moved <= 5 # about 1e-5 of the values lie within the snap window of a boundary + + +def test_quantization_is_antisymmetric_and_exact_on_integers() -> None: + for k in (0, 1, 7, 123456789): + assert _q(k * TOL, TOL) == k + assert _q(-k * TOL, TOL) == -k From 46635ce38646abe634ff8355f4579aea69d51689 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 03:00:59 +0800 Subject: [PATCH 28/32] fix(runtime, exporter): a script can model and export STEP in a read-only, unattended run (#89) DECISION: three changes for the three obstacles of the issue, each the smallest that removes the obstacle. No new API name, no relocatable state directory, no rewrite of rule R3. 1. A run without the cache touches nothing next to the source (the crash). PROBLEM: `run_notebook(path, cache=False)` failed on a read-only source directory (`Errno 30` on Linux, `WinError 5` here): `_run` ended with `write_dependencies`, which writes `__marimo__/.../deps.json` beside the notebook, and `exclusive()` made the state directory for its lock. REPRODUCED on Windows with an ACL that denies creating entries in the directory, on the issue's own notebook: PermissionError at runner.py `write_dependencies`. After the change the same run prints `ok` and creates nothing. WHY THIS FIX: the dependency record exists for the cache (cache entries are keyed on the files it lists); a run that keeps no entries has no reason to move it, and moving it left the record and the entries out of step. The lock exists to protect pruning and that record, neither of which a run without the cache does. A run WITH the cache still writes its state and still fails loudly when it cannot: asking for persistence and silently not getting it would be worse. REJECTED: an environment variable or argument that moves the state directory (more surface, and the problem is that there should be no state, not that it is in the wrong place); swallowing the OSError (hides real cache failures). 2. The exporters take a build result (one step, no `.scadpkg` on disk). PROBLEM: STEP from a freshly built Part needed `capture(result, path)` and then the exporter on that path, and the default capture tessellates a scene nobody reads: 1.85 s against 0.26 s for the issue's 36-hole plate. WHY THIS FIX: `build_product_package` already accepts a build result or a definition and can leave the scene out; `read_product_package_translation_units`, which every exporter and translator goes through, now builds that package in memory when it is given neither a package nor bytes nor a path. So STEP, STL, OBJ, MJCF and FreeCAD all gain the one-step form with the same validation, under the existing names. A value that is no product still raises TypeError. REJECTED: `exporter.step.export_part(part_or_solid, path)` as the issue suggests: a bare Part or solid has no definition (feature graph, topology snapshot, hashes), those come from `@scad.part`; and a second family of names beside `export_product_package_to_*` (the tests pin that these live only in `scad.exporter`). 3. The skill lists the plain script as an entry for a single-script task. PROBLEM: R3 says a model is a marimo notebook; an agent given "one script, `--output part.step`" read the skill and chose another library. WHY THIS FIX: R3 keeps its force for everything a person opens or reuses; it gains one exception, a task that itself asks for a single script, and the template (which I ran in a read-only directory: STEP written, source untouched) lives in the export domain page next to the rule it relaxes. THIS IS A POLICY DECISION OF THE OWNER, made here provisionally: R3 was a deliberate rule, and the PR says so. Not done: the issue's timings were not rerun on Linux, only on this Windows machine (same direction). Checked: test_notebook_runtime and the cache tests (123 passed), test_product_exporter/packages/capture/assembly pass with the three new exporter tests, test_skill_lint passes. test_skill_build::test_files_without_directives_copied_verbatim fails on the base commit as well. Co-Authored-By: Claude Sonnet 5.5 --- .../skill/references/discipline/core-rules.md | 4 +- .../domains/export-and-translation.md | 38 ++++++++++++++++- src/simplecadapi/exporter/_surface_mesh.py | 4 +- src/simplecadapi/exporter/mjcf.py | 4 +- src/simplecadapi/exporter/obj.py | 4 +- src/simplecadapi/exporter/step.py | 7 ++-- src/simplecadapi/exporter/stl.py | 4 +- src/simplecadapi/runtime/deps.py | 8 +++- src/simplecadapi/runtime/runner.py | 7 +++- src/simplecadapi/translator/package_units.py | 17 ++++++-- test/test_notebook_runtime.py | 28 +++++++++++++ test/test_product_exporter.py | 41 ++++++++++++++++++- 12 files changed, 143 insertions(+), 23 deletions(-) diff --git a/docs/skill/references/discipline/core-rules.md b/docs/skill/references/discipline/core-rules.md index d30545d0..c005d5ab 100644 --- a/docs/skill/references/discipline/core-rules.md +++ b/docs/skill/references/discipline/core-rules.md @@ -15,7 +15,9 @@ the durable export call `capture(result, path)`, whose two required arguments ar notebook; one assembly notebook per product, composing children with `scad.use(...)`; parameters live in the notebook that consumes them. Cell boundaries, `_private` names and `var()` follow `discipline/notebook-cell-design.md`; block structure and the mandatory header `# ---- feature: () ----` follow `discipline/feature-tree-convention.md`. Read both -before writing any cell. +before writing any cell. The one exception is a task that itself asks for a single script +(`python model.py --output part.step`, an unattended batch): that is a plain script, see "One-shot script" in +`domains/export-and-translation.md`; the notebook rules do not apply to it. **R4. Sketch first.** 2D profiles and planar paths go through the sketch API; primitives only when the shape is entirely the basic form. diff --git a/docs/skill/references/domains/export-and-translation.md b/docs/skill/references/domains/export-and-translation.md index 1e72bbea..6b9d3242 100644 --- a/docs/skill/references/domains/export-and-translation.md +++ b/docs/skill/references/domains/export-and-translation.md @@ -60,8 +60,10 @@ Read the exact page under `references/docs/api/` for every API used: ## Export rules -- Translate only from a validated `.scadpkg` package: `capture` the product - first, then export from the package path. +- Translate only from a validated product package: `capture` the product + first, then export from the package path. In a one-shot script (below) the + exporters also take the build result itself and package it in memory, with + the same validation and without the scene projection nobody reads. - STL and OBJ share one direct OpenCASCADE tessellation of the evaluated BREP; both contain the same oriented triangles. Control curved-surface accuracy with `linear_deflection` (chordal deviation in product units) and @@ -75,6 +77,38 @@ Read the exact page under `references/docs/api/` for every API used: - Use `validate_step_roundtrip_rdescriptor` when a STEP export must survive re-import (roundtrip-sensitive consumers). +## One-shot script (no notebook, no `.scadpkg` on disk) + +For a batch or unattended task whose deliverable is "one script, run as +`python model.py --output part.step`" (read-only source directory, no editor), +a plain script is the supported entry. A notebook is still the form for +anything a person will open, edit or reuse (core rule R3). The runtime keeps +no state next to the source when it runs without the cache. + +```python +import argparse +from pathlib import Path + +import simplecadapi as scad + + +@scad.part(id="plate", project_root=Path(__file__).parent) +def build_plate() -> scad.Part: + body = scad.make_box_rsolid(width=120.0, height=120.0, depth=10.0) + return scad.make_part_rpart("plate", body) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + args = parser.parse_args() + report = scad.exporter.export_product_package_to_step(build_plate(), args.output) + print(report.output_path, report.definition_ids) +``` + +A notebook run unattended in a read-only directory uses +`run_notebook(path, cache=False)`. + ## Validation gates - Read the returned export report and print its actual fields per format diff --git a/src/simplecadapi/exporter/_surface_mesh.py b/src/simplecadapi/exporter/_surface_mesh.py index 9d27c4c3..501cdc6d 100644 --- a/src/simplecadapi/exporter/_surface_mesh.py +++ b/src/simplecadapi/exporter/_surface_mesh.py @@ -10,7 +10,7 @@ from ..artifacts.assembly_io import materialize_definition from simplecadapi._cpp import Assembly, make_compound_from_assembly_rcompound, Part from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -122,7 +122,7 @@ def tessellate_solid( def tessellate_product_surface( - data: ProductPackageInput, + data: ProductInput, *, linear_deflection: float, angular_deflection_degrees: float, diff --git a/src/simplecadapi/exporter/mjcf.py b/src/simplecadapi/exporter/mjcf.py index 455df56f..e8aa7f21 100644 --- a/src/simplecadapi/exporter/mjcf.py +++ b/src/simplecadapi/exporter/mjcf.py @@ -20,7 +20,7 @@ from simplecadapi._cpp import Assembly, Part from ..product.placement import Placement, relative_placement from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) from ._surface_mesh import tessellate_solid @@ -339,7 +339,7 @@ def _part_materials(units: tuple[Any, ...]) -> dict[str, tuple[Part, float, bool def export_product_package_to_mjcf( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, diff --git a/src/simplecadapi/exporter/obj.py b/src/simplecadapi/exporter/obj.py index 07f77fcc..b917c7a4 100644 --- a/src/simplecadapi/exporter/obj.py +++ b/src/simplecadapi/exporter/obj.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from pathlib import Path -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import tessellate_product_surface @@ -26,7 +26,7 @@ class ProductOBJExportReport: def export_product_package_to_obj( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/exporter/step.py b/src/simplecadapi/exporter/step.py index d8a823a7..b421ad56 100644 --- a/src/simplecadapi/exporter/step.py +++ b/src/simplecadapi/exporter/step.py @@ -13,7 +13,7 @@ from ..artifacts import materialize_definition from simplecadapi._cpp import Assembly, Part from ..translator.package_units import ( - ProductPackageInput, + ProductInput, read_product_package_translation_units, ) @@ -152,10 +152,11 @@ def visit(value: Part | Assembly) -> str: def export_product_package_to_step( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, ) -> ProductSTEPExportReport: - """Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP.""" + """Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of + a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene.""" import simplecadapi._cpp as native diff --git a/src/simplecadapi/exporter/stl.py b/src/simplecadapi/exporter/stl.py index ae8fab83..f1bcca9b 100644 --- a/src/simplecadapi/exporter/stl.py +++ b/src/simplecadapi/exporter/stl.py @@ -8,7 +8,7 @@ import numpy as np -from ..translator.package_units import ProductPackageInput +from ..translator.package_units import ProductInput from ._surface_mesh import TessellatedSurfaceMesh, tessellate_product_surface @@ -51,7 +51,7 @@ def _write_binary_stl(path: Path, mesh: TessellatedSurfaceMesh) -> None: def export_product_package_to_stl( - data: ProductPackageInput, + data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, diff --git a/src/simplecadapi/runtime/deps.py b/src/simplecadapi/runtime/deps.py index fbf691e2..1a5a1c5d 100644 --- a/src/simplecadapi/runtime/deps.py +++ b/src/simplecadapi/runtime/deps.py @@ -99,7 +99,7 @@ def state_dir(config: NotebookConfig) -> Path: @contextmanager -def exclusive(config: NotebookConfig) -> Iterator[None]: +def exclusive(config: NotebookConfig, *, enabled: bool = True) -> Iterator[None]: """Hold the notebook's state directory against other processes. Worker processes may run the same notebook at once (two notebooks that @@ -107,9 +107,13 @@ def exclusive(config: NotebookConfig) -> Iterator[None]: lock is between processes only: a run of a notebook this process is already running (a ``use`` cycle, an editor next to a headless run) goes on without it, as it always did. Without ``fcntl`` (Windows) it is a - no-op. + no-op. With ``enabled=False`` (a run that keeps no state: nothing to + prune, nothing to protect) it is a no-op too, and creates nothing. """ + if not enabled: + yield + return directory = state_dir(config) key = str(directory) with _held_lock: diff --git a/src/simplecadapi/runtime/runner.py b/src/simplecadapi/runtime/runner.py index 5d17e614..b1007e13 100644 --- a/src/simplecadapi/runtime/runner.py +++ b/src/simplecadapi/runtime/runner.py @@ -170,7 +170,7 @@ def run() -> NotebookRun: # Errors still come through. "marimo" is the logger every marimo # module shares (lazy.py's record shows its file name, "lazy", which # is not the logger's name). - with suppress_warnings_logs("marimo"), exclusive(config): + with suppress_warnings_logs("marimo"), exclusive(config, enabled=cache): return _run(config, values, cache=cache, through=through, product=product) # marimo keeps its runtime context per thread, and a notebook run needs @@ -322,7 +322,10 @@ def _run( # Cells restored from the cache ran no ``use`` this time; the files they # depend on are among those the previous run recorded. recorded = frozenset(used) | (previous if restored else frozenset()) - write_dependencies(config, recorded) + if cache: + # The record belongs to the cache: its entries are keyed on the files it lists, so a run that keeps no entries must + # not move it (and a run without the cache must work where the source directory is read-only, issue #89). + write_dependencies(config, recorded) dependencies = module_files | recorded if cache and cell_cache is not None: # A cell that runs a child notebook depends on the record just written: diff --git a/src/simplecadapi/translator/package_units.py b/src/simplecadapi/translator/package_units.py index 445666f2..8cd131cc 100644 --- a/src/simplecadapi/translator/package_units.py +++ b/src/simplecadapi/translator/package_units.py @@ -11,6 +11,7 @@ from ..artifacts.part_definition import PartDefinition from ..product.packages import ( ProductPackage, + build_product_package, read_product_package, validate_product_package, ) @@ -18,6 +19,8 @@ Definition = PartDefinition | AssemblyDefinition ProductPackageInput = ProductPackage | bytes | bytearray | memoryview | str | Path +# What an exporter takes besides a package: the result of a build (``@scad.part`` / ``@scad.assemble``) or its definition. +ProductInput = ProductPackageInput | Definition | Any @dataclass(frozen=True, slots=True) @@ -70,15 +73,22 @@ def model_payload(self) -> dict[str, Any]: def read_product_package_translation_units( - data: ProductPackageInput, + data: ProductInput, ) -> tuple[ProductPackage, tuple[ProductPackageTranslationUnit, ...]]: - """Return a validated package and dependency-first definition units.""" + """Return a validated package and dependency-first definition units. + + *data* is a package, the bytes or path of one, or (issue #89) the result of a build or a definition: a script that + only wants a STEP file need not write a ``.scadpkg`` first. That package is built in memory without the scene + projection (tessellated meshes), which no exporter reads and which took most of the time of a default capture. + """ if isinstance(data, ProductPackage): validate_product_package(data) package = data - else: + elif isinstance(data, (bytes, bytearray, memoryview, str, Path)): package = read_product_package(data) + else: + package = build_product_package(data, include_scene=False) ordered: list[Definition] = [] visited: set[tuple[str, str]] = set() @@ -122,5 +132,6 @@ def visit(definition: Definition) -> None: __all__ = [ "ProductPackageInput", "ProductPackageTranslationUnit", + "ProductInput", "read_product_package_translation_units", ] diff --git a/test/test_notebook_runtime.py b/test/test_notebook_runtime.py index f87d21d3..755616dd 100644 --- a/test/test_notebook_runtime.py +++ b/test/test_notebook_runtime.py @@ -13,6 +13,7 @@ import importlib.metadata import io import json +import os import pickle import re import sys @@ -326,6 +327,33 @@ def test_without_the_cache_nothing_is_stored(self) -> None: self.assertFalse((state_dir(run.config) / "cache").exists()) self.assertEqual(self.statuses(run_notebook(self.bracket)), ["ran"] * 4) + def test_without_the_cache_the_source_directory_is_left_alone(self) -> None: + """Issue #89: a run that keeps no state must not create the state directory (``__marimo__``) next to the source, + so a notebook can run where the source directory is read-only (a container's ``-v src:/src:ro``).""" + + run_notebook(self.bracket, cache=False) + self.assertFalse((self.root / "__marimo__").exists()) + self.assertFalse(state_dir(read_notebook_config(self.bracket)).exists()) + + @unittest.skipUnless( + os.name == "posix" and hasattr(os, "geteuid") and os.geteuid() != 0, "needs a non-root POSIX user to make a directory read-only" + ) + def test_without_the_cache_a_read_only_source_directory_runs(self) -> None: + directories = [self.root, self.root / "lib"] + modes = [directory.stat().st_mode for directory in directories] + for directory in directories: + directory.chmod(0o555) + try: + run = run_notebook(self.bracket, cache=False) + finally: + for directory, mode in zip(directories, modes): + directory.chmod(mode) + self.assertEqual(run.definition.definition_id, "bracket") + + def test_with_the_cache_the_dependency_record_is_still_written(self) -> None: + run = run_notebook(self.bracket) + self.assertTrue((state_dir(run.config) / "deps.json").is_file()) + def test_overrides_skip_the_defining_cell(self) -> None: plain = run_notebook(self.bracket) wide = run_notebook(self.bracket, overrides={"width": 30.0}) diff --git a/test/test_product_exporter.py b/test/test_product_exporter.py index 11cd34ae..6999a9db 100644 --- a/test/test_product_exporter.py +++ b/test/test_product_exporter.py @@ -147,7 +147,7 @@ def build_root() -> scad.Assembly: return scad.build_product_package(build_root()) -def _build_cylinder_package(): +def _build_cylinder_result(): @scad.part( id="cylinder", project_root=Path(__file__).parent, @@ -156,7 +156,11 @@ def build_cylinder() -> scad.Part: body = scad.make_cylinder_rsolid(radius=4.0, height=8.0) return scad.make_part_rpart("cylinder", body) - return scad.build_product_package(build_cylinder()) + return build_cylinder() + + +def _build_cylinder_package(): + return scad.build_product_package(_build_cylinder_result()) class TestProductExporter(unittest.TestCase): @@ -182,6 +186,39 @@ def test_exporters_are_available_only_from_exporter_namespace(self): self.assertFalse(hasattr(scad, "export_obj")) self.assertFalse(hasattr(scad.translator, "ap242_translator")) + def test_a_build_result_exports_in_one_step_without_a_scene(self): + """Issue #89: a script that only wants a file need not capture a ``.scadpkg`` first, nor pay for the scene + (the tessellated projection no exporter reads).""" + + from unittest import mock + + from simplecadapi.product import packages + + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + with mock.patch.object(packages, "_scene_projection", side_effect=AssertionError("the scene was built")): + step = scad.exporter.export_product_package_to_step(result, root / "one.step") + stl = scad.exporter.export_product_package_to_stl(result, root / "one.stl") + self.assertEqual(step.definition_ids, ("cylinder",)) + self.assertGreater((root / "one.step").stat().st_size, 0) + self.assertGreater((root / "one.stl").stat().st_size, 0) + self.assertEqual(stl.output_path, (root / "one.stl").resolve()) + + def test_a_definition_exports_like_its_build_result(self): + result = _build_cylinder_result() + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + from_result = scad.exporter.export_product_package_to_step(result, root / "a.step") + from_definition = scad.exporter.export_product_package_to_step(result.definition, root / "b.step") + self.assertEqual(from_result.definition_ids, from_definition.definition_ids) + self.assertEqual(from_result.metadata_item_count, from_definition.metadata_item_count) + + def test_a_value_that_is_no_product_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp_dir: + with self.assertRaises(TypeError): + scad.exporter.export_product_package_to_step(object(), Path(tmp_dir) / "x.step") + def test_nested_package_preserves_ap242_product_structure_and_placements(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) From 35a4e5efa9f59936597c1b063d95e29cee09c59c Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 03:31:21 +0800 Subject: [PATCH 29/32] fix(kernel): cut and intersect unify what they leave split, and a blend never returns an invalid solid (#88) DECISION: three changes, in this order of importance, each independent of the others. (1) a fillet or chamfer whose result OCCT calls invalid is an error; (2) cut_rsolid, cut_rsolid_list and intersect_rsolid unify the same-domain faces and edges of their result by default, with `clean=False` to keep the raw result, as union already does; (3) the diagnosis names split faces instead of blaming the radius. No compatibility shim for records made before `clean` existed: replay requires the parameter, like every other recorded one. PROBLEM (reproduced on the issue's two cases, current dev build on Windows, OCCT 7.9.3) A cut through the side of a box with a cylinder splits the cylinder wall along the cylinder's parametric seam into two faces, and the top edge of the notch into a long and a short arc. union unified (ShapeUpgrade_UnifySameDomain) and cut did not. OCCT then fillets that top edge wrongly: - case A: BRepCheck-invalid solid, volume 28305.59 (should be 29604.46), no error at all: `fillet_rsolid` checked only IsDone and volume > 0; - case B: refusal, and the diagnosis said "the radius exceeds the 2.5 mm of face room" - the half of the wall the seam cut off - so the advised radius was not the limit. Same geometry, other libraries: CadQuery and build123d clean after a boolean by default and give 29604.46 / 29811.31. (1) THE EXIT CHECK IS THE MOST IMPORTANT `assert_blend_valid`, next to the volume check in `finish_blend` (fillet and chamfer): BRepCheck_Analyzer on the result; invalid while the input was valid is a BlendValidityViolation, which leads the error message ("the kernel returned broken geometry"), like the existing volume violation. Not raised when the input was already invalid (an imported solid is not the blend's fault). It turns "wrong volume found three operations later" into an error at the operation that caused it, whatever else is or is not done about cleaning. (2) CLEAN BY DEFAULT, WITH THE HISTORY FOLLOWING SDK booleans go through `tracked_cut` / `tracked_intersect`, which ask the kernel's history what became of every face (that is how tags and lineage survive), so cleaning only the shape would break tracking. `BooleanOptions::clean` makes `run_boolean` unify the result, merge the unifier's history into the boolean's, and follow the section edges through it; the section-edge following was lifted out of `fuse_shapes_with_history` into one helper both use (fuse behaves as before). The unified shape replaces the result only when it has the same number of solids and is valid whenever the raw result was; otherwise the raw result stands. FreeCAD and build123d fall back the same way, and replicad's unconditional SimplifyResult has been seen to invalidate results (BarbourSmith/Abundance#1934), so the fallback is not decoration. `clean` is a keyword of the three functions (default True), recorded as params.clean like union's. C++ `cut_shapes`, `common_shapes` and `tracked_*` keep `clean=false` as their default so other callers (diagnosis, set booleans, std parts) are unchanged. Not done on purpose: OCCT's own SimplifyResult(true, true) that the issue suggests: the explicit unifier already gives the same merge and history, with a fallback we control. REPLAY: NO LEGACY BRANCH (owner's rule: do not keep old formats alive) `flag(p, "clean")` and `ctx.require_params(..., "clean")` fail on a graph recorded before this change, as they do for any missing parameter. Such a graph has to be recorded again. (3) THE DIAGNOSIS `classify_split_faces`, before the face-room probe: unify the solid, carry the selected edges over with the unifier's history, blend again with the same size; if that works the split faces are the cause, and the message says so and how to fix it (clean=True, or union_rsolid(clean=True)). The room probe stays for a radius that really is too large (test: radius 50 on the cleaned case is still reported as too large). EVIDENCE - Both cases now give valid fillets with volumes 29604.46 and 29811.31, equal to CadQuery's. - clean=False: A raises "not a valid solid" (it was returned), B raises "faces ... split on a surface ... clean=True". - test/test_boolean_clean.py (10 tests): face counts 8 against 9, volume unchanged by cleaning, cut_list and intersect, both cases, both clean=False diagnoses, a really-too-large radius, `clean` recorded, replay giving 8 / 9 faces. - A random sample, 120 cases of the issue's recipe (box minus 2-4 vertical boxes/cylinders on a grid, top edges, r 1-3), both modes: 240 results, none invalid and returned (clean=True 84 valid / 36 errors, clean=False 83 / 37). - Full Python suite on this machine: 2674 passed; 22 failed and 9 errors, none touching booleans (Windows paths, encodings, file locks, addon and shell integration, missing OCP, a math-library last digit in test_tolerance). NOT DONE / NOT KNOWN - The C++ unit tests (ctest) were not built or run here (SCAD_BUILD_TESTS is off for the wheel build). CutOptions::clean defaults to true, so a C++ test that compares a cut's topology may need its expectation updated: CI will say. - The issue's 16000-case run was not repeated, only the small sample above; it shows that no invalid result is returned, not the 0.4% rate of the issue (there is no old binary here to compare with). - A stored artifact or fingerprint of a model with a cut that left split faces changes: the faces are fewer. That is the point of the change; rebuild such models. - Unifying can still make a later blend worse on rare inputs (the issue saw 6 segfaults after unify in other libraries too); here the unify is inside the boolean, so a crash would show in the boolean. Co-Authored-By: Claude Sonnet 5.5 --- docs/skill/references/docs/api/cut_rsolid.md | 6 +- .../references/docs/api/cut_rsolid_list.md | 6 +- .../references/docs/api/intersect_rsolid.md | 6 +- .../docs/core/operation_graph_json_spec.md | 3 + src/cpp/bindings/python/bind_ops_features.cpp | 19 +-- src/cpp/include/scad/kernel/booleans.hpp | 12 +- .../include/scad/ops/features/booleans.hpp | 7 +- .../include/scad/ops/features/diagnostics.hpp | 13 +- src/cpp/include/scad/semantic/tracking.hpp | 9 +- src/cpp/src/kernel/booleans.cpp | 95 +++++++++---- src/cpp/src/ops/features/blend_diagnosis.cpp | 97 +++++++++++++ src/cpp/src/ops/features/blends.cpp | 7 +- src/cpp/src/ops/features/booleans.cpp | 11 +- src/cpp/src/ops/features/executors.cpp | 10 +- src/cpp/src/semantic/tracking_booleans.cpp | 13 +- src/simplecadapi/_glue/docstrings.json | 6 +- src/simplecadapi/recording/serializer.py | 12 +- test/test_boolean_clean.py | 128 ++++++++++++++++++ 18 files changed, 389 insertions(+), 71 deletions(-) create mode 100644 test/test_boolean_clean.py diff --git a/docs/skill/references/docs/api/cut_rsolid.md b/docs/skill/references/docs/api/cut_rsolid.md index d315f164..0a863726 100644 --- a/docs/skill/references/docs/api/cut_rsolid.md +++ b/docs/skill/references/docs/api/cut_rsolid.md @@ -3,7 +3,7 @@ ## API Definition ```python -def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces') +def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces', clean=True) ``` *Source: operators/boolean.py* @@ -33,6 +33,10 @@ sequences, and returns a single `Solid`. - **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta. +### clean + +- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ### on_split - **Description**: What a cut that splits the body (a slot through a bar) returns. diff --git a/docs/skill/references/docs/api/cut_rsolid_list.md b/docs/skill/references/docs/api/cut_rsolid_list.md index 930d17f1..e3e54020 100644 --- a/docs/skill/references/docs/api/cut_rsolid_list.md +++ b/docs/skill/references/docs/api/cut_rsolid_list.md @@ -3,7 +3,7 @@ ## API Definition ```python -def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full') +def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full', clean=True) ``` *Source: operators/boolean.py* @@ -39,6 +39,10 @@ a through slot. Use ``cut_rsolid`` when the result must be one solid. - **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta. +### clean + +- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ## Returns list[Solid]: Every piece of the cut, in kernel order; empty when the tools diff --git a/docs/skill/references/docs/api/intersect_rsolid.md b/docs/skill/references/docs/api/intersect_rsolid.md index 023b0b3e..26ce58b7 100644 --- a/docs/skill/references/docs/api/intersect_rsolid.md +++ b/docs/skill/references/docs/api/intersect_rsolid.md @@ -3,7 +3,7 @@ ## API Definition ```python -def intersect_rsolid(*solids) +def intersect_rsolid(*solids, clean=True) ``` *Source: operators/boolean.py* @@ -26,6 +26,10 @@ error instead of returning an empty list. - **Description**: One or more Solid objects or sequences of Solid. Nested sequences are flattened before processing. +### clean + +- **Description**: When True (the default) the faces and edges the intersection leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result. + ## Returns Solid: The overlap region as a single solid. diff --git a/docs/skill/references/docs/core/operation_graph_json_spec.md b/docs/skill/references/docs/core/operation_graph_json_spec.md index b0936824..f703e88f 100644 --- a/docs/skill/references/docs/core/operation_graph_json_spec.md +++ b/docs/skill/references/docs/core/operation_graph_json_spec.md @@ -1567,12 +1567,14 @@ Notes: | --- | --- | --- | | `tool_count` | `int` | number of subtractive tool solids | | `skip_non_intersecting` | `bool` | whether non-intersecting tools are skipped | +| `clean` | `bool` | whether the faces and edges the cut leaves split were unified | Notes: - Public `cut_rsolid(...)` defaults this flag to `true` for interactive convenience. - Graph replay defaults missing `skip_non_intersecting` to `false` so malformed or drifting graphs fail diagnostically instead of silently skipping tools. - Multi-tool cut preserves the full topology delta chain across all performed tool cuts. +- `clean` has no replay default: a graph without it is rejected like one without `tool_count`. #### `make_intersect_rsolid` @@ -1583,6 +1585,7 @@ Notes: | Key | Type | Meaning | | --- | --- | --- | | `input_count` | `int` | number of intersected solids | +| `clean` | `bool` | whether the faces and edges the intersection leaves split were unified | Notes: diff --git a/src/cpp/bindings/python/bind_ops_features.cpp b/src/cpp/bindings/python/bind_ops_features.cpp index 383961f8..2a502ba3 100644 --- a/src/cpp/bindings/python/bind_ops_features.cpp +++ b/src/cpp/bindings/python/bind_ops_features.cpp @@ -683,9 +683,10 @@ void bind_booleans(py::module_& m) { m.def( "cut_rsolid", [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy, - const Choice& on_split) -> AnyOf { + const Choice& on_split, bool clean) -> AnyOf { const std::vector operands = flattened(solids, "cut_rsolid"); - const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + options.clean = clean; if (on_split.value == "error") return {py::cast(unlocked(false, [&] { return cut_rsolid(operands, options); }))}; if (on_split.value != "pieces") @@ -696,27 +697,29 @@ void bind_booleans(py::module_& m) { return {solids_to_py(outcome.pieces)}; // the cut split the body: every piece, ordered by centroid }, py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full", - py::arg("on_split") = "pieces", + py::arg("on_split") = "pieces", py::arg("clean") = true, "Compute the boolean difference of solids: the first is the base, the rest are subtracted in order."); m.def( "cut_rsolid_list", - [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy) { + [](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy, bool clean) { const std::vector operands = flattened(solids, "cut_rsolid_list"); - const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + CutOptions options{skip_non_intersecting, policy_text(tracking_policy)}; + options.clean = clean; return solids_to_py(unlocked(false, [&] { return cut_rsolid_list(operands, options); })); }, py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full", + py::arg("clean") = true, "Compute the boolean difference of solids and keep every piece: a cut that splits the body returns each part, " "and one that consumes the body returns an empty list."); m.def( "intersect_rsolid", - [](const py::args& solids) { + [](const py::args& solids, bool clean) { const std::vector operands = flattened(solids, "intersect_rsolid"); - return unlocked(false, [&] { return intersect_rsolid(operands); }); + return unlocked(false, [&] { return intersect_rsolid(operands, clean); }); }, - "Compute the boolean intersection of solids."); + py::kw_only(), py::arg("clean") = true, "Compute the boolean intersection of solids."); m.def( "boolean_rsolidlist", diff --git a/src/cpp/include/scad/kernel/booleans.hpp b/src/cpp/include/scad/kernel/booleans.hpp index 38b8f620..a5f96f02 100644 --- a/src/cpp/include/scad/kernel/booleans.hpp +++ b/src/cpp/include/scad/kernel/booleans.hpp @@ -22,6 +22,10 @@ struct BooleanOptions { bool fill_history = false; // keep BRepTools_History + section edges std::optional fuzzy; // fuzzy tolerance (SetFuzzyValue) bool glue = false; // BOPAlgo_GlueShift instead of GlueOff + // Unify the same-domain faces and edges of the result (what a cut leaves behind: a cylinder wall split along its + // seam, an edge split in two). The history, when kept, and the section edges follow the clean-up. A clean-up that + // fails, or that turns a valid result into an invalid one, is dropped: the raw result is the answer. + bool clean = false; }; struct BooleanResult { @@ -55,10 +59,10 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector& shap TopoDS_Shape fuse_shapes(const std::vector& shapes, bool glue = false, std::optional tol = std::nullopt, bool clean = true); -// body - every tool. `body` itself when there are no tools. -TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools); +// body - every tool. `body` itself when there are no tools. With `clean`, the result is unified (see BooleanOptions). +TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools, bool clean = false); -// Intersection of all shapes, folded left to right. -TopoDS_Shape common_shapes(const std::vector& shapes); +// Intersection of all shapes, folded left to right. With `clean`, each step's result is unified. +TopoDS_Shape common_shapes(const std::vector& shapes, bool clean = false); } // namespace scad::kernel diff --git a/src/cpp/include/scad/ops/features/booleans.hpp b/src/cpp/include/scad/ops/features/booleans.hpp index 86e74811..c9c3d4a4 100644 --- a/src/cpp/include/scad/ops/features/booleans.hpp +++ b/src/cpp/include/scad/ops/features/booleans.hpp @@ -27,6 +27,9 @@ struct CutOptions { // cut_rsolid_list: order the pieces by centroid (x, then y, then z) instead of kernel order, so piece i is the same // piece on every OCCT version. Recorded as params.piece_order = "centroid" only when set. bool order_pieces = false; + // Unify the faces and edges the cut leaves split (a cylinder wall cut along its seam), as union does: OCCT fails to + // fillet such a split face, or returns an invalid solid. Recorded as params.clean. + bool clean = true; }; // What a cut that may split leaves: one Solid, or (`solid` empty) every piece, ordered by centroid. @@ -47,8 +50,8 @@ std::vector cut_rsolid_list(const std::vector& solids, const CutOp // cut_rsolid_list with order_pieces (recorded as make_cut_rsolid_list). An empty result is still an error. CutOutcome cut_rsolid_keep_split(const std::vector& solids, const CutOptions& options = {}); -// The overlap of all `solids`. Error "intersect_rsolid". -Solid intersect_rsolid(const std::vector& solids); +// The overlap of all `solids`; `clean` as for CutOptions. Error "intersect_rsolid". +Solid intersect_rsolid(const std::vector& solids, bool clean = true); // The error a non-Solid argument of union_rsolid / cut_rsolid / intersect_rsolid raises // (a binding that flattens Python arguments throws it for an item that is not a solid). diff --git a/src/cpp/include/scad/ops/features/diagnostics.hpp b/src/cpp/include/scad/ops/features/diagnostics.hpp index 2d966b7d..74568e17 100644 --- a/src/cpp/include/scad/ops/features/diagnostics.hpp +++ b/src/cpp/include/scad/ops/features/diagnostics.hpp @@ -21,7 +21,8 @@ namespace scad { // A classified fillet / chamfer failure. struct BlendDiagnosis { // "tangent_adjacent_faces" | "sliver_edges_in_selection" | "size_exceeds_face" | - // "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict" + // "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict" | + // "faces_split_on_one_surface" std::string failure_kind; std::string what_happened; std::vector possible_causes; @@ -54,6 +55,16 @@ class BlendVolumeViolation : public ValueError { void assert_blend_volume_monotonic(const Solid& source, const Solid& result, std::string_view operation_kind, std::optional size_value, const std::vector& edges); +// Exit check of a blend, next to the volume check: the result must be a valid solid (BRepCheck_Analyzer) whenever +// the input was. OCCT reports `IsDone` for fillets it got wrong (a face split along a seam, issue #88) and hands back +// an invalid solid with a wrong volume, which only a later boolean or an export finds. Throws BlendValidityViolation. +class BlendValidityViolation : public BlendVolumeViolation { + public: + using BlendVolumeViolation::BlendVolumeViolation; +}; +void assert_blend_valid(const Solid& source, const Solid& result, std::string_view operation_kind, + std::optional size_value, const std::vector& edges); + // A classified boolean failure. struct BooleanDiagnosis { std::string failure_kind; // "disjoint" | "non_manifold_contact" diff --git a/src/cpp/include/scad/semantic/tracking.hpp b/src/cpp/include/scad/semantic/tracking.hpp index dfde8f60..77ab47df 100644 --- a/src/cpp/include/scad/semantic/tracking.hpp +++ b/src/cpp/include/scad/semantic/tracking.hpp @@ -179,8 +179,9 @@ ModificationHistory modification_history(const BRepTools_History& history, const // tracked_cut. Face history of body ("body") and tool ("tool"), plus the // section edges. `solid` of the result is the *first* solid of the kernel's // result, empty when the cut left none; membership is judged against the whole -// kernel result. ValueError when the algorithm did not complete. -TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool); +// kernel result. ValueError when the algorithm did not complete. With `clean` the result is unified (same-domain +// faces and edges merged, see kernel::BooleanOptions::clean) and the history follows the clean-up. +TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool, bool clean = false); // tracked_union: a two-solid fuse with `track_union_history` on top. ValueError // unless the fuse yields exactly one solid. Glue is opt-in (see kernel::fuse_shapes_with_history). @@ -195,8 +196,8 @@ TrackedBooleanResult track_union_history(const std::vector& solids, const const BRepTools_History& history, const std::vector& section_edges = {}); -// tracked_intersect. -TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool); +// tracked_intersect. `clean` as for tracked_cut. +TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool, bool clean = false); namespace tracking { // How many topology-history queries the tracked operations have made in this process. A graph-policy diff --git a/src/cpp/src/kernel/booleans.cpp b/src/cpp/src/kernel/booleans.cpp index 46aff8eb..e62b6359 100644 --- a/src/cpp/src/kernel/booleans.cpp +++ b/src/cpp/src/kernel/booleans.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,62 @@ std::vector tail_of(const std::vector& shapes) { return std::vector(shapes.begin() + 1, shapes.end()); } +bool is_valid_shape(const TopoDS_Shape& shape) { + return !shape.IsNull() && BRepCheck_Analyzer(shape).IsValid() == Standard_True; +} + +// Follow each section edge through a clean-up: it may have been merged into a longer edge or dropped with a removed +// face. Only the edges that are in `cleaned` survive, each once. +std::vector follow_section_edges(const opencascade::handle& clean_history, + const TopoDS_Shape& cleaned, + const std::vector& section_edges) { + SubShapeIndex result(cleaned); + TopTools_IndexedMapOfShape seen; + std::vector survivors; + for (const TopoDS_Shape& edge : section_edges) { + std::vector candidates; + for (const TopoDS_Shape& s : clean_history->Modified(edge)) + candidates.push_back(s); + for (const TopoDS_Shape& s : clean_history->Generated(edge)) + candidates.push_back(s); + if (candidates.empty() && !clean_history->IsRemoved(edge)) + candidates.push_back(edge); + for (const TopoDS_Shape& candidate : candidates) { + if (!result.contains(candidate)) + continue; + const int before = seen.Extent(); + seen.Add(candidate); + if (seen.Extent() > before) + survivors.push_back(candidate); + } + } + return survivors; +} + +// BooleanOptions::clean. The unified shape replaces the result only when it is as good: the same number of solids, and +// valid whenever the raw result was (FreeCAD and build123d fall back to the raw result the same way; unifying every +// boolean result has been seen to invalidate some). +void unify_result(BooleanResult& result) { + try { + ShapeUpgrade_UnifySameDomain unifier(result.shape, /*UnifyEdges=*/true, /*UnifyFaces=*/true, + /*ConcatBSplines=*/true); + unifier.Build(); + const TopoDS_Shape cleaned = unifier.Shape(); + if (cleaned.IsNull() || solids_of(cleaned).size() != solids_of(result.shape).size()) + return; + if (!is_valid_shape(cleaned) && is_valid_shape(result.shape)) + return; + if (!result.history.IsNull()) { + const opencascade::handle clean_history = unifier.History(); + result.history->Merge(clean_history); + result.section_edges = follow_section_edges(clean_history, cleaned, result.section_edges); + } + result.shape = cleaned; + } catch (const Standard_Failure&) { + // the raw result stands + } +} + } // namespace BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std::vector& tools, @@ -86,6 +143,8 @@ BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std: out.section_edges.clear(); } } + if (options.clean) + unify_result(out); return out; }); } @@ -138,29 +197,7 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector& shap const opencascade::handle clean_history = unifier.History(); out.history->Merge(clean_history); - // Follow each section edge through the clean-up: it may have been merged - // into a longer edge or dropped with a removed face. - SubShapeIndex result(out.shape); - TopTools_IndexedMapOfShape seen; - std::vector survivors; - for (const TopoDS_Shape& edge : out.section_edges) { - std::vector candidates; - for (const TopoDS_Shape& s : clean_history->Modified(edge)) - candidates.push_back(s); - for (const TopoDS_Shape& s : clean_history->Generated(edge)) - candidates.push_back(s); - if (candidates.empty() && !clean_history->IsRemoved(edge)) - candidates.push_back(edge); - for (const TopoDS_Shape& candidate : candidates) { - if (!result.contains(candidate)) - continue; - const int before = seen.Extent(); - seen.Add(candidate); - if (seen.Extent() > before) - survivors.push_back(candidate); - } - } - out.section_edges = std::move(survivors); + out.section_edges = follow_section_edges(clean_history, out.shape, out.section_edges); return out; }); } @@ -182,21 +219,25 @@ TopoDS_Shape fuse_shapes(const std::vector& shapes, bool glue, std return clean ? clean_shape(fused.shape) : fused.shape; } -TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools) { +TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector& tools, bool clean) { if (tools.empty()) return body; - const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools); + BooleanOptions options; + options.clean = clean; + const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools, options); if (!cut.done) throw ValueError("OCP cut failed"); return cut.shape; } -TopoDS_Shape common_shapes(const std::vector& shapes) { +TopoDS_Shape common_shapes(const std::vector& shapes, bool clean) { if (shapes.empty()) throw ValueError("common_shapes requires at least one shape"); TopoDS_Shape result = shapes.front(); for (std::size_t i = 1; i < shapes.size(); ++i) { - const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]}); + BooleanOptions options; + options.clean = clean; + const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]}, options); if (!common.done) throw ValueError("OCP common failed"); result = common.shape; diff --git a/src/cpp/src/ops/features/blend_diagnosis.cpp b/src/cpp/src/ops/features/blend_diagnosis.cpp index 6db55152..029e88a8 100644 --- a/src/cpp/src/ops/features/blend_diagnosis.cpp +++ b/src/cpp/src/ops/features/blend_diagnosis.cpp @@ -1,5 +1,13 @@ // Why a fillet or chamfer failed: the probes of operators/_diagnostics.py, in its order. #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include @@ -153,6 +161,77 @@ std::optional classify_tangent_adjacency(const Case& c) { return std::nullopt; } +// A boolean that was not cleaned leaves faces that lie on one surface split (a cylinder wall cut along its seam is two +// faces). The blend of an edge along such a face fails, or goes wrong, and the room probe below then blames the radius: +// the "face room" it measures is only the half the seam cut off. Unify the same-domain faces, carry the selected edges +// over with the history, and blend again: when that works, the split faces are the cause (issue #88). +std::optional classify_split_faces(const Case& c) { + ShapeUpgrade_UnifySameDomain unifier(c.solid.wrapped(), /*UnifyEdges=*/true, /*UnifyFaces=*/true, + /*ConcatBSplines=*/true); + unifier.Build(); + const TopoDS_Shape unified = unifier.Shape(); + if (unified.IsNull()) + return std::nullopt; + const auto faces_in = [](const TopoDS_Shape& shape) { + TopTools_IndexedMapOfShape faces; + TopExp::MapShapes(shape, TopAbs_FACE, faces); + return faces.Extent(); + }; + const int split = faces_in(c.solid.wrapped()) - faces_in(unified); + if (split <= 0) + return std::nullopt; + + const opencascade::handle history = unifier.History(); + TopTools_MapOfShape carried; + std::vector edges; + for (const Edge& edge : c.edges) { + std::vector now; + for (const TopoDS_Shape& s : history->Modified(edge.wrapped())) + now.push_back(s); + if (now.empty() && !history->IsRemoved(edge.wrapped())) + now.push_back(edge.wrapped()); + for (const TopoDS_Shape& s : now) { + if (s.ShapeType() == TopAbs_EDGE && carried.Add(s)) + edges.push_back(TopoDS::Edge(s)); + } + } + if (edges.empty()) + return std::nullopt; + + const bool chamfer = c.words.size == "distance"; + const char* kind = chamfer ? "chamfer" : "fillet"; + bool works = false; + if (chamfer) { + BRepFilletAPI_MakeChamfer blend(unified); + for (const TopoDS_Edge& edge : edges) + blend.Add(c.size_value, edge); + blend.Build(); + works = blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + } else { + BRepFilletAPI_MakeFillet blend(unified); + for (const TopoDS_Edge& edge : edges) + blend.Add(c.size_value, edge); + blend.Build(); + works = blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + } + if (!works) + return std::nullopt; + + BlendDiagnosis out; + out.failure_kind = "faces_split_on_one_surface"; + out.what_happened = std::to_string(split) + " face(s) of the solid are split on a surface they share with a neighbour " + + "(typically a boolean made without clean, or a cylinder wall cut along its seam); the same " + kind + + " " + c.words.size + " " + g4(c.size_value) + + " mm succeeds once those faces are unified, so the " + c.words.size + " is not the limit"; + out.possible_causes = {"A cut or intersection ran with clean=False and left a face split along its seam.", + "The solid came from a source that does not unify faces (an imported or hand-built shape)."}; + out.measurements = {{"split_faces", static_cast(split), "faces"}, {c.words.size, c.size_value, "mm"}}; + out.repair = {"Make the cut / intersect with clean=True (the default), or pass the solid through union_rsolid(solid, " + "clean=True) before the " + std::string(kind) + ".", + std::string("Keep the ") + c.words.size + " as it is: it is not too large."}; + return out; +} + std::optional classify_blend_room(const Case& c) { for (const Edge& edge : c.edges) { const auto* faces = c.faces_of(edge); @@ -336,6 +415,8 @@ std::optional diagnose_blend_failure(const Solid& solid, const s return found; if (auto found = classify_tangent_adjacency(c)) return found; + if (auto found = classify_split_faces(c)) + return found; if (auto found = classify_blend_room(c)) return found; if (auto found = classify_partial_smooth_chain(c)) @@ -426,4 +507,20 @@ void assert_blend_volume_monotonic(const Solid& source, const Solid& result, std } } +void assert_blend_valid(const Solid& source, const Solid& result, std::string_view operation_kind, + std::optional size_value, const std::vector& edges) { + // The cheap side first: a valid result is the normal case; the input is checked only to tell "OCCT broke it" from + // "it came in broken" (an imported solid that was already invalid is not the blend's fault). + const bool result_valid = occt_guard([&] { return BRepCheck_Analyzer(result.wrapped()).IsValid() == Standard_True; }); + if (result_valid) + return; + const bool source_valid = occt_guard([&] { return BRepCheck_Analyzer(source.wrapped()).IsValid() == Standard_True; }); + if (!source_valid) + return; + throw BlendValidityViolation(std::string(operation_kind) + + " result is not a valid solid (BRepCheck_Analyzer) although the input was: the kernel " + "returned broken geometry (size=" + + size_text(size_value) + ", selected_edges=" + std::to_string(edges.size()) + ")"); +} + } // namespace scad diff --git a/src/cpp/src/ops/features/blends.cpp b/src/cpp/src/ops/features/blends.cpp index ed3b4877..2be7580c 100644 --- a/src/cpp/src/ops/features/blends.cpp +++ b/src/cpp/src/ops/features/blends.cpp @@ -52,7 +52,11 @@ struct BlendAttempt { spec.possible_causes = diagnosis->possible_causes; const std::string details = - violation != nullptr ? "BlendVolumeViolation: " + std::string(violation->what()) : technical_details_of(error); + violation != nullptr ? std::string(dynamic_cast(&error) != nullptr + ? "BlendValidityViolation: " + : "BlendVolumeViolation: ") + + violation->what() + : technical_details_of(error); std::vector evidence; if (diagnosis && !diagnosis->evidence_edges.empty()) { // The solid with the implicated edges in orange; the same failure at the same size is drawn once per process @@ -85,6 +89,7 @@ Solid finish_blend(const Solid& solid, TrackedResult tracked, const BlendAttempt double check_size, FeatureRecord& rec, const BlendTags& tags, std::string_view patch_role, const std::function& between = {}) { assert_blend_volume_monotonic(solid, Solid(tracked.shape), kind, check_size, attempt.edges); + assert_blend_valid(solid, Solid(tracked.shape), kind, check_size, attempt.edges); inherit_metadata(tracked.shape, {&solid}); rec.param("edge_count", Value(static_cast(attempt.edges.size()))); diff --git a/src/cpp/src/ops/features/booleans.cpp b/src/cpp/src/ops/features/booleans.cpp index 2359a761..b04bd7a5 100644 --- a/src/cpp/src/ops/features/booleans.cpp +++ b/src/cpp/src/ops/features/booleans.cpp @@ -323,12 +323,12 @@ CutRun run_cuts(const std::vector& solids, const CutOptions& options, Tra std::vector cut_pieces; if (policy == TrackingPolicy::Full) { - TrackedBooleanResult tracked = tracked_cut(piece, candidate); + TrackedBooleanResult tracked = tracked_cut(piece, candidate, options.clean); cut_pieces = std::move(tracked.solids); run.deltas.push_back(std::move(tracked.delta)); merge_entries(run.entries, std::move(tracked.delta_entries)); } else { - for (const TopoDS_Solid& part : kernel::solids_of(kernel::cut_shapes(s1, {s2}))) + for (const TopoDS_Solid& part : kernel::solids_of(kernel::cut_shapes(s1, {s2}, options.clean))) cut_pieces.emplace_back(part); } run.intermediates.push_back(piece); @@ -350,6 +350,7 @@ void record_cut(const std::vector& solids, const CutOptions& options, Tra FeatureRecord& rec) { rec.param("tool_count", Value(static_cast(solids.size() - 1))) .param("skip_non_intersecting", Value(options.skip_non_intersecting)) + .param("clean", Value(options.clean)) .param("tracking_policy", Value(std::string(to_string(policy)))); add_operands(rec, solids); } @@ -485,7 +486,7 @@ std::vector cut_rsolid_list(const std::vector& solids, const CutOp // --------------------------------------------------------------------------- // intersect // --------------------------------------------------------------------------- -Solid intersect_rsolid(const std::vector& solids) { +Solid intersect_rsolid(const std::vector& solids, bool clean) { bool empty_result = false; try { return occt_guard([&]() -> Solid { @@ -503,7 +504,7 @@ Solid intersect_rsolid(const std::vector& solids) { const Solid& candidate = solids[i]; if (result_solid.wrapped().IsNull() || candidate.wrapped().IsNull()) throw ValueError("The input solid is invalid; the intersection cannot be computed."); - TrackedBooleanResult tracked = tracked_intersect(result_solid, candidate); + TrackedBooleanResult tracked = tracked_intersect(result_solid, candidate, clean); if (!tracked.solid) { empty_result = true; throw ValueError("The intersection is empty or OCC returned no valid solid"); @@ -522,7 +523,7 @@ Solid intersect_rsolid(const std::vector& solids) { result_solid.apply_tag("solid.boolean.intersect", false); FeatureRecord rec(kIntersectOp); - rec.param("input_count", Value(static_cast(solids.size()))); + rec.param("input_count", Value(static_cast(solids.size()))).param("clean", Value(clean)); add_operands(rec, solids); FinalizeSpec spec; spec.record = std::move(rec).take(); diff --git a/src/cpp/src/ops/features/executors.cpp b/src/cpp/src/ops/features/executors.cpp index d3dcd53d..555fe075 100644 --- a/src/cpp/src/ops/features/executors.cpp +++ b/src/cpp/src/ops/features/executors.cpp @@ -154,17 +154,19 @@ void register_booleans(reactive::ExecutorRegistry& registry) { return one(union_rsolid(rest(in, 0, in.size()), options)); }); registry.add("make_cut_rsolid", [](Params p, Inputs in, const ScalarValues&) { - return one( - cut_rsolid(rest(in, 0, in.size()), {flag(p, "skip_non_intersecting"), text(p, "tracking_policy")})); + CutOptions options{flag(p, "skip_non_intersecting"), text(p, "tracking_policy")}; + options.clean = flag(p, "clean"); + return one(cut_rsolid(rest(in, 0, in.size()), options)); }); registry.add("make_cut_rsolid_list", [](Params p, Inputs in, const ScalarValues&) { CutOptions options{flag(p, "skip_non_intersecting"), text(p, "tracking_policy")}; + options.clean = flag(p, "clean"); const Value* order = p.find("piece_order"); // absent on records made before ordered pieces options.order_pieces = order != nullptr && order->is_string() && order->as_string() == "centroid"; return many(cut_rsolid_list(rest(in, 0, in.size()), options)); }); - registry.add("make_intersect_rsolid", [](Params, Inputs in, const ScalarValues&) { - return one(intersect_rsolid(rest(in, 0, in.size()))); + registry.add("make_intersect_rsolid", [](Params p, Inputs in, const ScalarValues&) { + return one(intersect_rsolid(rest(in, 0, in.size()), flag(p, "clean"))); }); registry.add("make_boolean_rsolidlist", [](Params p, Inputs in, const ScalarValues&) { const auto left = static_cast(param(p, "left_count").as_int()); diff --git a/src/cpp/src/semantic/tracking_booleans.cpp b/src/cpp/src/semantic/tracking_booleans.cpp index fa51049c..2e326b1d 100644 --- a/src/cpp/src/semantic/tracking_booleans.cpp +++ b/src/cpp/src/semantic/tracking_booleans.cpp @@ -54,10 +54,12 @@ std::string input_role(std::size_t index) { // body op tool, with history. `Solid(result)` of the Python SDK is the first // solid of the kernel's answer (empty when there is none); the history filter // is the whole answer. -TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, const Solid& tool, const char* failure) { +TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, const Solid& tool, const char* failure, + bool clean) { return occt_guard([&] { kernel::BooleanOptions options; options.fill_history = true; + options.clean = clean; const kernel::BooleanResult run = kernel::run_boolean(op, body.wrapped(), {tool.wrapped()}, options); if (!run.done) throw ValueError(failure); @@ -87,12 +89,13 @@ TrackedBooleanResult track_boolean(kernel::BooleanOp op, const Solid& body, cons } // namespace -TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool) { - return track_boolean(kernel::BooleanOp::Cut, body, tool, "Boolean cut failed: OCC build did not complete"); +TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool, bool clean) { + return track_boolean(kernel::BooleanOp::Cut, body, tool, "Boolean cut failed: OCC build did not complete", clean); } -TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool) { - return track_boolean(kernel::BooleanOp::Common, body, tool, "Boolean intersect failed: OCC build did not complete"); +TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool, bool clean) { + return track_boolean(kernel::BooleanOp::Common, body, tool, "Boolean intersect failed: OCC build did not complete", + clean); } TrackedBooleanResult tracked_union(const Solid& body, const Solid& tool, bool glue, double tol) { diff --git a/src/simplecadapi/_glue/docstrings.json b/src/simplecadapi/_glue/docstrings.json index 8024dc76..8cbf5358 100644 --- a/src/simplecadapi/_glue/docstrings.json +++ b/src/simplecadapi/_glue/docstrings.json @@ -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. 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`.", + "cut_rsolid": "Compute the boolean difference of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, tools with no meaningful intersection are\n ignored for interactive convenience. Graph replay records this flag and\n should use False for strict diagnostic workflows.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n clean: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n on_split: What a cut that splits the body (a slot through a bar) returns.\n ``\"pieces\"`` (default): every piece, as ``list[Solid]`` ordered by centroid\n (x, then y, then z), recorded like ``cut_rsolid_list``. ``\"error\"``: raise,\n for code that needs exactly one Solid.\n\nReturns:\n Solid: The cut result solid, when the result is one piece.\n list[Solid]: The pieces (a ``scad.Pieces``), when the cut split the body\n (``on_split=\"pieces\"``). Operations that take one shape run on every piece\n (``cut_rsolid(pieces, tool)``, ``translate_shape(pieces, ...)``) and return\n ``Pieces``; a selector's cardinality is checked over all pieces. They are notebook values: a Part holds exactly one solid, so make one Part of each piece\n (an Assembly of them is the several-piece result), not ``make_part_rpart(body=pieces)``.\n\nRaises:\n SimpleCADError: The cut produced no solid, or (``on_split=\"error\"``) split\n the body. Use ``cut_rsolid_list`` to always get a list.\n\nUsage:\n Accepts a base solid followed by one or more tool solids, including nested\n sequences, and returns a single `Solid`.", "diagnose_blend_failure": "Classify a fillet/chamfer failure; never raises.\n\nProbe order (cheapest explanation wins):\n\n1. Tangent adjacency — the two faces meeting at a selected edge are\n already tangent (dihedral ~180°), so the blend surface degenerates to\n zero width regardless of size.\n2. Room — blend size exceeds the distance from the edge to the opposite\n boundary of an adjacent face.\n3. Interaction — per-edge diagnostic rerun (``retry_single``, capped at\n 64 edges) isolates edges that fail alone; when every edge passes\n alone but the joint blend fails, opposite blends on a shared face\n meeting tangentially (2×size >= face room) is the explanation.", "diagnose_boolean_failure": "Classify a boolean failure from its operands; never raises.\n\nModel: operands within ``tol`` of each other form one connected cluster\n(transitively). More than one cluster means the union is genuinely\nseparated — report the nearest inter-cluster pair. A single cluster that\nstill failed to fuse contains a touch-without-overlap pair — report it as\nnon-manifold contact. Returns None when no pair explains the failure.", "diff_sketch_rsketchdiff": "Report what an edit did: entity and constraint ids created, removed or changed between two sketches.\n\nReturns a dict with ``created``, ``removed``, ``changed`` (entity ids, points included) and\n``created_constraints``, ``removed_constraints``, ``changed_constraints``. Not recorded in the graph.", @@ -180,7 +180,7 @@ "inspect_sketch_rsketchprofiles": "List the profiles a sketch offers: the indices ``make_face_from_sketch_rface`` and ``make_wire_from_sketch_rwire`` take.\n\nReturns ``{\"profiles\": [...], \"unusable\": [...]}``. Each profile is ``{index, id, kind, closed, entity_ids, point_ids, reversed}``: ``kind`` is ``circle``, ``edge_loop`` or ``edge_chain``; ``entity_ids`` are in walking order and ``reversed`` says which edges are walked from end to start. ``unusable`` words why an edge set is no profile (a branch point carrying more than two edges, for one). A free circle is a profile; a loop is a connected set of edges whose points each carry exactly two. Not recorded in the graph; pass ``strict=False`` for a sketch that does not solve cleanly.", "inspect_sketch_rsketchresult": "Inspect sketch constraints by running the solver without recording graph nodes.", "internal_tag_binding": "Create a source-marked binding for transitional internal annotations.", - "intersect_rsolid": "Compute the boolean intersection of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing.\n\nReturns:\n Solid: The overlap region as a single solid.\n\nUsage:\n Accepts one or more solids, including nested sequences, and returns a single\n `Solid`. If the inputs do not overlap meaningfully, the API raises a clear\n error instead of returning an empty list.", + "intersect_rsolid": "Compute the boolean intersection of solids.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing.\n clean: When True (the default) the faces and edges the intersection leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n\nReturns:\n Solid: The overlap region as a single solid.\n\nUsage:\n Accepts one or more solids, including nested sequences, and returns a single\n `Solid`. If the inputs do not overlap meaningfully, the API raises a clear\n error instead of returning an empty list.", "is_normalized_tag": "Check whether a tag matches the normalized dot-token format.", "isolated_recording": "Run a nested definition build as if it were called at top level.\n\nThe caller's session is hidden (no active session, recording not\nsuspended) and the world coordinate system is current, so the nested\nbuild records only into its own session and does not depend on where it\nwas called from. Everything is restored on exit.", "legacy_tag_binding": "Import one flat compatibility token without inventing assignment intent.", @@ -347,7 +347,7 @@ "validate_placement_ticks": "Validate a persisted tick frame with exact integer arithmetic.", "value": "Build a value key extractor for ordering and projection in QL.\n\nArgs:\n path: Property or metadata path to resolve.\n default: Fallback value when the path is missing.\n\nReturns:\n Serializable key function for `Query.order_by(...)`.", "var": "Create a physical or legacy scalar variable.\n\n``tolerance=0.1`` declares a symmetric ``+/-0.1`` tolerance. Use a\n``(lower_deviation, upper_deviation)`` pair for an asymmetric tolerance.\n``tolerance_unit`` defaults to ``unit`` when a nominal unit is declared.\nValues are converted to canonical CAD units only when evaluated, so the\ndeclaration and serialized expression node preserve the user's units.\n\nVariables without ``unit`` retain legacy unitless behavior. A unit-aware\nexpression cannot mix declared-unit variables with legacy variables.", - "cut_rsolid_list": "Compute the boolean difference of solids and keep every piece.\n\nA cut can split the body (a slot through a bar leaves two parts) or consume it\n(a tool that encloses the whole body leaves nothing). ``cut_rsolid`` refuses\nsuch a result because it returns exactly one ``Solid``; this operator returns\nthem all. A tool is cut from every piece it meets, so several tools may split\na body into several parts.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, a tool that meets none of the current\n pieces is ignored; when False it raises.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n\nReturns:\n list[Solid]: Every piece of the cut, in kernel order; empty when the tools\n consume the base.\n\nRaises:\n SimpleCADError: An input is not a valid Solid, or the kernel could not\n compute the cut.\n\nUsage:\n ``pieces = cut_rsolid_list(bar, slot)`` returns both halves of a bar cut by\n a through slot. Use ``cut_rsolid`` when the result must be one solid.\n", + "cut_rsolid_list": "Compute the boolean difference of solids and keep every piece.\n\nA cut can split the body (a slot through a bar leaves two parts) or consume it\n(a tool that encloses the whole body leaves nothing). ``cut_rsolid`` refuses\nsuch a result because it returns exactly one ``Solid``; this operator returns\nthem all. A tool is cut from every piece it meets, so several tools may split\na body into several parts.\n\nArgs:\n solids: One or more Solid objects or sequences of Solid. Nested sequences are\n flattened before processing; the first solid is the base, the rest are\n subtracted in order.\n skip_non_intersecting: When True, a tool that meets none of the current\n pieces is ignored; when False it raises.\n tracking_policy: FULL computes topology history and lineage. GRAPH keeps\n the replayable operation node without computing a TopoDelta.\n clean: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.\n\nReturns:\n list[Solid]: Every piece of the cut, in kernel order; empty when the tools\n consume the base.\n\nRaises:\n SimpleCADError: An input is not a valid Solid, or the kernel could not\n compute the cut.\n\nUsage:\n ``pieces = cut_rsolid_list(bar, slot)`` returns both halves of a bar cut by\n a through slot. Use ``cut_rsolid`` when the result must be one solid.\n", "Body": "The solid of a Part, with its display name, colour and opacity.\n\n``Body(body_id, solid, name=None, color=None, opacity=None)``: ``body_id`` is an\nidentifier (``\"body\"`` for the body of a Part), ``solid`` the body's Solid, ``name`` an optional\ndisplay name, ``color`` an optional display colour (RGB floats in [0, 1]) and\n``opacity`` an optional display opacity in [0, 1]. A one-body Part has one Body with\nthe id ``\"body\"``. The colour overrides the Part's material colour for display only;\nset it on an existing Part with ``set_body_appearance_rpart``.", "measure_part_mass_rmassreport": "Measure the volume, mass and centre of mass of a Part.\n\nArgs:\n part: The Part to measure.\n density: Density to use instead of the material's; ``None`` uses the\n Part's material, and without a material density every mass is ``None``.\n density_unit: Unit of ``density``: ``kg/m^3`` (default), ``g/cm^3``,\n ``kg/mm^3`` or ``g/mm^3``.\n\nReturns:\n MassReport: ``bodies`` (the Part's one ``BodyMass``: ``body_id``, ``volume_mm3``,\n ``mass_kg``, ``centroid_mm``) and the totals of the part (``volume_mm3``,\n ``mass_kg``, ``centroid_mm``).\n\nRaises:\n SimpleCADError: The value is not a Part, or the density or its unit is invalid.\n\nUsage:\n ``scad.measure_part_mass_rmassreport(bracket).mass_kg`` is the mass of the part.\n The part's material gives the density unless ``density`` is passed.", "inspect_curve_rcurvedata": "Read the NURBS data of an edge's curve: what the curve edits work on.\n\nReturns ``{type, analytic, editable, conversion, degree, poles, weights, knots, multiplicities, periodic, rational, closed, domain, reversed, parameter_range}``. ``poles`` are points in the current coordinate system, indices are 0-based (the ``moves`` of ``move_curve_poles_redge``); ``parameter_range`` is the edge's own range (where ``insert_curve_knot_redge`` may insert). For a line, circle or other analytic curve ``editable`` is false, ``conversion`` is ``\"convert_to_bspline_redge\"`` and the data is what that conversion would produce. Not recorded in the graph.", diff --git a/src/simplecadapi/recording/serializer.py b/src/simplecadapi/recording/serializer.py index 7ba05f78..4898733f 100644 --- a/src/simplecadapi/recording/serializer.py +++ b/src/simplecadapi/recording/serializer.py @@ -4290,7 +4290,7 @@ def _vector(value: Any) -> Any: if op_name == "make_cut_rsolid": ctx.require_params( - node.node_id, op_name, params, ("tool_count",) + node.node_id, op_name, params, ("tool_count", "clean") ) if len(node.inputs) < 2: if ctx.strict: @@ -4320,13 +4320,14 @@ def _vector(value: Any) -> Any: ) ), tracking_policy=str(params.get("tracking_policy", "full")), + clean=bool(params.get("clean")), ) _store_outputs(node, result) continue if op_name == "make_cut_rsolid_list": ctx.require_params( - node.node_id, op_name, params, ("tool_count", "input_refs") + node.node_id, op_name, params, ("tool_count", "input_refs", "clean") ) inputs = _ordered_input_shapes(ctx, graph, outputs, node, params) if len(inputs) < 2: @@ -4340,6 +4341,7 @@ def _vector(value: Any) -> Any: _param(ctx, node.node_id, op_name, params, "skip_non_intersecting", False) ), "tracking_policy": str(params.get("tracking_policy", "full")), + "clean": bool(params.get("clean")), } if params.get("piece_order") == "centroid": # a split recorded by cut_rsolid(on_split="pieces"): the pieces in centroid order @@ -4405,14 +4407,16 @@ def _vector(value: Any) -> Any: if op_name == "make_intersect_rsolid": ctx.require_params( - node.node_id, op_name, params, ("input_count",) + node.node_id, op_name, params, ("input_count", "clean") ) all_solids = [ cast(Solid, shape) for shape in _all_input_outputs(ctx, outputs, node) ] if len(all_solids) >= 2: - result = ops.intersect_rsolid(all_solids[0], all_solids[1:]) + result = ops.intersect_rsolid( + all_solids[0], all_solids[1:], clean=bool(params.get("clean")) + ) _store_outputs(node, result) elif ctx.strict: ctx.fail( diff --git a/test/test_boolean_clean.py b/test/test_boolean_clean.py new file mode 100644 index 00000000..0c5844a0 --- /dev/null +++ b/test/test_boolean_clean.py @@ -0,0 +1,128 @@ +"""cut / intersect unify what they leave split, and a blend never hands back an invalid solid (issue #88). + +A cut through the side of a box with a cylinder splits the cylinder wall along the cylinder's parametric seam into two +faces, and the top edge of the notch into a long and a short arc. OCCT then fillets that top edge wrongly: it returns +an invalid solid with a volume that is too small (case A), or refuses and the diagnosis blames the radius (case B). +The same geometry fillets fine once the faces are unified, which is what union already did and cut did not. +""" + +from __future__ import annotations + +import unittest + +import simplecadapi as scad +from simplecadapi import ql +from simplecadapi._cpp.occt.BRepCheck import BRepCheck_Analyzer +from simplecadapi.errors import SimpleCADError +from simplecadapi.recording.graph import GraphSession + +# the issue's two placements of the cylinder; volumes are what CadQuery and build123d (which clean by default) give +CASE_A = (0.0, 17.5) +CASE_B = (-12.5, -17.5) +VOLUME_A = 29604.46 +VOLUME_B = 29811.31 + + +def notch(cx: float, cy: float, **options): + box = scad.make_box_rsolid(width=40, height=40, depth=20, bottom_face_center=(0, 0, 0)) + cylinder = scad.make_cylinder_rsolid(radius=10, height=40, bottom_face_center=(cx, cy, 10)) + return scad.cut_rsolid(box, cylinder, **options) + + +def top_edges(solid, z: float = 20.0): + return [ + edge + for edge in ql.edges().resolve(solid) + if abs(edge.get_start_vertex().get_coordinates()[2] - z) < 1e-6 + and abs(edge.get_end_vertex().get_coordinates()[2] - z) < 1e-6 + ] + + +def is_valid(solid) -> bool: + return bool(BRepCheck_Analyzer(solid.wrapped).IsValid()) + + +def face_count(solid) -> int: + return len(ql.faces().resolve(solid)) + + +class TestCutCleans(unittest.TestCase): + def test_cut_unifies_the_wall_the_seam_split(self): + self.assertEqual(face_count(notch(*CASE_A)), 8) + self.assertEqual(face_count(notch(*CASE_A, clean=False)), 9) + + def test_the_cleaned_cut_keeps_its_volume(self): + self.assertAlmostEqual(notch(*CASE_A).get_volume(), notch(*CASE_A, clean=False).get_volume(), places=3) + + def test_cut_list_and_intersect_clean_by_default_too(self): + box = scad.make_box_rsolid(width=40, height=40, depth=20, bottom_face_center=(0, 0, 0)) + cylinder = scad.make_cylinder_rsolid(radius=10, height=40, bottom_face_center=(0, 17.5, 10)) + (piece,) = scad.cut_rsolid_list(box, cylinder) + self.assertEqual(face_count(piece), 8) + (raw,) = scad.cut_rsolid_list(box, cylinder, clean=False) + self.assertEqual(face_count(raw), 9) + # the part of the box inside the cylinder: a cylinder wall cut along its seam is two faces unless unified + inside = scad.intersect_rsolid(box, cylinder) + raw_inside = scad.intersect_rsolid(box, cylinder, clean=False) + self.assertLess(face_count(inside), face_count(raw_inside)) + self.assertAlmostEqual(inside.get_volume(), raw_inside.get_volume(), places=3) + + +class TestFilletAfterCut(unittest.TestCase): + def test_case_a_fillets_to_a_valid_solid_with_the_right_volume(self): + cut = notch(*CASE_A) + filleted = scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertTrue(is_valid(filleted)) + self.assertAlmostEqual(filleted.get_volume(), VOLUME_A, delta=0.05) + + def test_case_b_fillets_with_the_radius_the_diagnosis_called_too_large(self): + cut = notch(*CASE_B) + filleted = scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertTrue(is_valid(filleted)) + self.assertAlmostEqual(filleted.get_volume(), VOLUME_B, delta=0.05) + + def test_an_unclean_cut_no_longer_yields_an_invalid_fillet(self): + """Case A without the clean-up: OCCT's fillet is invalid. It used to be returned; now it is an error.""" + cut = notch(*CASE_A, clean=False) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + self.assertIn("not a valid solid", str(raised.exception)) + + def test_an_unclean_cut_names_the_split_faces_not_the_radius(self): + """Case B without the clean-up: the kernel refuses, and the diagnosis says why instead of 'reduce the radius'.""" + cut = notch(*CASE_B, clean=False) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=3.0) + message = str(raised.exception) + self.assertIn("split on a surface", message) + self.assertIn("clean=True", message) + self.assertNotIn("Reduce the radius", message) + + def test_a_radius_that_really_is_too_large_is_still_reported_as_such(self): + cut = notch(*CASE_A) + with self.assertRaises(SimpleCADError) as raised: + scad.fillet_rsolid(solid=cut, edges=top_edges(cut), radius=50.0) + self.assertNotIn("split on a surface", str(raised.exception)) + + +class TestRecordedClean(unittest.TestCase): + """``clean`` is a recorded parameter like ``skip_non_intersecting``; replay reads it, there is no default for a + record that lacks it.""" + + def record(self, **options): + with GraphSession() as session: + notch(*CASE_A, **options) + return session + + def test_the_flag_is_recorded(self): + self.assertTrue(self.record().graph.leaf_nodes()[0].params["clean"]) + self.assertFalse(self.record(clean=False).graph.leaf_nodes()[0].params["clean"]) + + def test_replay_reproduces_the_recorded_result(self): + for options, faces in (({}, 8), ({"clean": False}, 9)): + (replayed,) = scad.replay_graph(self.record(**options).graph) # the leaf: the cut + self.assertEqual(face_count(replayed), faces, options) + + +if __name__ == "__main__": + unittest.main() From af696badd8fea16d31d1134090604ae3fb05ed89 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 03:32:23 +0800 Subject: [PATCH 30/32] docs: regenerate the export API pages (the exporters now take a build result, ProductInput) The committed API pages must equal what tools/auto_docs_gen.py writes (test_auto_docs_gen); the new parameter type of export_product_package_to_step/stl/obj/mjcf was not in them, which failed Python 3.10-3.13 in CI. The cut and intersect pages the generator also touched belong to #92 (they come from the native module installed here, which has that branch's signatures) and are left out. Co-Authored-By: Claude Sonnet 5.5 --- .../references/docs/api/export_product_package_to_mjcf.md | 2 +- .../references/docs/api/export_product_package_to_obj.md | 2 +- .../references/docs/api/export_product_package_to_step.md | 5 +++-- .../references/docs/api/export_product_package_to_stl.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/skill/references/docs/api/export_product_package_to_mjcf.md b/docs/skill/references/docs/api/export_product_package_to_mjcf.md index a41c05fd..2058ec01 100644 --- a/docs/skill/references/docs/api/export_product_package_to_mjcf.md +++ b/docs/skill/references/docs/api/export_product_package_to_mjcf.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_mjcf(data: ProductPackageInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport +def export_product_package_to_mjcf(data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport ``` *Source: exporter/mjcf.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_obj.md b/docs/skill/references/docs/api/export_product_package_to_obj.md index 4e84a492..1c82c85b 100644 --- a/docs/skill/references/docs/api/export_product_package_to_obj.md +++ b/docs/skill/references/docs/api/export_product_package_to_obj.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_obj(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport +def export_product_package_to_obj(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport ``` *Source: exporter/obj.py* diff --git a/docs/skill/references/docs/api/export_product_package_to_step.md b/docs/skill/references/docs/api/export_product_package_to_step.md index 6882ff1a..235d85f1 100644 --- a/docs/skill/references/docs/api/export_product_package_to_step.md +++ b/docs/skill/references/docs/api/export_product_package_to_step.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_step(data: ProductPackageInput, output_path: str | Path) -> ProductSTEPExportReport +def export_product_package_to_step(data: ProductInput, output_path: str | Path) -> ProductSTEPExportReport ``` *Source: exporter/step.py* @@ -14,4 +14,5 @@ def export_product_package_to_step(data: ProductPackageInput, output_path: str | ## Description -Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP. +Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of +a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene. diff --git a/docs/skill/references/docs/api/export_product_package_to_stl.md b/docs/skill/references/docs/api/export_product_package_to_stl.md index 63c9f908..31d8e512 100644 --- a/docs/skill/references/docs/api/export_product_package_to_stl.md +++ b/docs/skill/references/docs/api/export_product_package_to_stl.md @@ -3,7 +3,7 @@ ## API Definition ```python -def export_product_package_to_stl(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport +def export_product_package_to_stl(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport ``` *Source: exporter/stl.py* From ee5668c31ae5da80199e7458754654bf59531649 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 04:06:38 +0800 Subject: [PATCH 31/32] test(cpp): follow the clean-up in the goldens, guard the diagnosis builders, format (CI of #88) DECISION: the roller-chain sprocket goldens and the C++ expectation move to the cleaned topology; they are not held at the old counts. The port is now deliberately different from the 2.2.6 reference on this point. WHAT CI SAID, AND WHY EACH ITEM IS RIGHT TO CHANGE - C++ doctest `std` (test_chain_bearing.cpp): the 12B sprocket has 39 faces, not 54. The sprocket is a body cut by tooth and roller profiles; cut now unifies the faces a cylinder seam had split. Volume, area, bounding box, tags are equal to the golden to 1e-6, so the geometry is the same and only the split faces are gone. - src/cpp/tests/golden/std_sprocket.json (faces 54 -> 39, edges 156 -> 111) and std_sprocket_unbored.json (35 -> 26 faces, 99 -> 72 edges): these files are recorded from the 2.2.6 Python reference SDK, which does not clean after a cut, and the suite refuses SCAD_REGOLDEN in CI, so they cannot be re-recorded there. I edited the four numbers by hand after comparing the port's whole output with the golden field by field: nothing else differs. Anyone who re-records from a reference that cleans will get the same numbers. - test_signatures_match_python: cut_rsolid has the keyword `clean` now. - OCCT guard audit: the trial blend of the new split-faces probe built BRepFilletAPI_Make* outside occt_guard. It runs under occt_guard now (a crash inside is a KernelError, which the diagnosis already swallows). - clang-format 18.1.8 (installed from pip into a scratch venv): blends.cpp and blend_diagnosis.cpp. CHECKED HERE: src/cpp/tests/python 556 passed (2 failures, test_locator_checks and test_failures, are Windows-only and did not fail in CI), check_occt_guard --strict 0, check_format.sh clean. The doctest executables were not built here; the CI run of this commit is the check for them. Co-Authored-By: Claude Sonnet 5.5 --- src/cpp/src/ops/features/blend_diagnosis.cpp | 41 ++++++++++--------- src/cpp/src/ops/features/blends.cpp | 10 ++--- src/cpp/tests/cpp/std/test_chain_bearing.cpp | 2 +- src/cpp/tests/golden/std_sprocket.json | 4 +- .../tests/golden/std_sprocket_unbored.json | 4 +- .../python/test_ops_features_booleans.py | 2 +- 6 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/cpp/src/ops/features/blend_diagnosis.cpp b/src/cpp/src/ops/features/blend_diagnosis.cpp index 029e88a8..85355abb 100644 --- a/src/cpp/src/ops/features/blend_diagnosis.cpp +++ b/src/cpp/src/ops/features/blend_diagnosis.cpp @@ -162,9 +162,9 @@ std::optional classify_tangent_adjacency(const Case& c) { } // A boolean that was not cleaned leaves faces that lie on one surface split (a cylinder wall cut along its seam is two -// faces). The blend of an edge along such a face fails, or goes wrong, and the room probe below then blames the radius: -// the "face room" it measures is only the half the seam cut off. Unify the same-domain faces, carry the selected edges -// over with the history, and blend again: when that works, the split faces are the cause (issue #88). +// faces). The blend of an edge along such a face fails, or goes wrong, and the room probe below then blames the +// radius: the "face room" it measures is only the half the seam cut off. Unify the same-domain faces, carry the +// selected edges over with the history, and blend again: when that works, the split faces are the cause (issue #88). std::optional classify_split_faces(const Case& c) { ShapeUpgrade_UnifySameDomain unifier(c.solid.wrapped(), /*UnifyEdges=*/true, /*UnifyFaces=*/true, /*ConcatBSplines=*/true); @@ -200,35 +200,38 @@ std::optional classify_split_faces(const Case& c) { const bool chamfer = c.words.size == "distance"; const char* kind = chamfer ? "chamfer" : "fillet"; - bool works = false; - if (chamfer) { - BRepFilletAPI_MakeChamfer blend(unified); - for (const TopoDS_Edge& edge : edges) - blend.Add(c.size_value, edge); - blend.Build(); - works = blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; - } else { + const bool works = occt_guard([&] { + if (chamfer) { + BRepFilletAPI_MakeChamfer blend(unified); + for (const TopoDS_Edge& edge : edges) + blend.Add(c.size_value, edge); + blend.Build(); + return blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + } BRepFilletAPI_MakeFillet blend(unified); for (const TopoDS_Edge& edge : edges) blend.Add(c.size_value, edge); blend.Build(); - works = blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; - } + return blend.IsDone() && BRepCheck_Analyzer(blend.Shape()).IsValid() == Standard_True; + }); if (!works) return std::nullopt; BlendDiagnosis out; out.failure_kind = "faces_split_on_one_surface"; - out.what_happened = std::to_string(split) + " face(s) of the solid are split on a surface they share with a neighbour " + - "(typically a boolean made without clean, or a cylinder wall cut along its seam); the same " + kind + - " " + c.words.size + " " + g4(c.size_value) + + out.what_happened = std::to_string(split) + + " face(s) of the solid are split on a surface they share with a neighbour " + + "(typically a boolean made without clean, or a cylinder wall cut along its seam); the same " + + kind + " " + c.words.size + " " + g4(c.size_value) + " mm succeeds once those faces are unified, so the " + c.words.size + " is not the limit"; out.possible_causes = {"A cut or intersection ran with clean=False and left a face split along its seam.", "The solid came from a source that does not unify faces (an imported or hand-built shape)."}; out.measurements = {{"split_faces", static_cast(split), "faces"}, {c.words.size, c.size_value, "mm"}}; - out.repair = {"Make the cut / intersect with clean=True (the default), or pass the solid through union_rsolid(solid, " - "clean=True) before the " + std::string(kind) + ".", - std::string("Keep the ") + c.words.size + " as it is: it is not too large."}; + out.repair = { + "Make the cut / intersect with clean=True (the default), or pass the solid through union_rsolid(solid, " + "clean=True) before the " + + std::string(kind) + ".", + std::string("Keep the ") + c.words.size + " as it is: it is not too large."}; return out; } diff --git a/src/cpp/src/ops/features/blends.cpp b/src/cpp/src/ops/features/blends.cpp index 2be7580c..276bae4c 100644 --- a/src/cpp/src/ops/features/blends.cpp +++ b/src/cpp/src/ops/features/blends.cpp @@ -52,11 +52,11 @@ struct BlendAttempt { spec.possible_causes = diagnosis->possible_causes; const std::string details = - violation != nullptr ? std::string(dynamic_cast(&error) != nullptr - ? "BlendValidityViolation: " - : "BlendVolumeViolation: ") + - violation->what() - : technical_details_of(error); + violation != nullptr + ? std::string(dynamic_cast(&error) != nullptr ? "BlendValidityViolation: " + : "BlendVolumeViolation: ") + + violation->what() + : technical_details_of(error); std::vector evidence; if (diagnosis && !diagnosis->evidence_edges.empty()) { // The solid with the implicated edges in orange; the same failure at the same size is drawn once per process diff --git a/src/cpp/tests/cpp/std/test_chain_bearing.cpp b/src/cpp/tests/cpp/std/test_chain_bearing.cpp index cfa728b4..8ed582a9 100644 --- a/src/cpp/tests/cpp/std/test_chain_bearing.cpp +++ b/src/cpp/tests/cpp/std/test_chain_bearing.cpp @@ -11,7 +11,7 @@ using doctest::Approx; TEST_CASE("sprocket: ISO 606 12B candidate") { const Solid sprocket = make_roller_chain_sprocket_rsolid(18, 19.05, 12.07, 11.1, 10.2); CHECK(sprocket.volume() == Approx(98201.67369173263).epsilon(1e-6)); - CHECK(sprocket.faces().size() == 54); + CHECK(sprocket.faces().size() == 39); // 54 before cut unified the faces a cylinder seam split (issue #88) const Value meta = sprocket.get_metadata("std.chain.roller_sprocket"); CHECK(meta.find("n_teeth")->as_int() == 18); CHECK(number(meta, "pitch_radius") == Approx(19.05 / (2.0 * std::sin(kPi / 18)))); diff --git a/src/cpp/tests/golden/std_sprocket.json b/src/cpp/tests/golden/std_sprocket.json index e703e28a..784c0a06 100644 --- a/src/cpp/tests/golden/std_sprocket.json +++ b/src/cpp/tests/golden/std_sprocket.json @@ -20,8 +20,8 @@ 59.38481593313661, 11.1 ], - "edges": 156, - "faces": 54, + "edges": 111, + "faces": 39, "tags": [ "solid.boolean.cut" ], diff --git a/src/cpp/tests/golden/std_sprocket_unbored.json b/src/cpp/tests/golden/std_sprocket_unbored.json index f305a0e0..d3b67db0 100644 --- a/src/cpp/tests/golden/std_sprocket_unbored.json +++ b/src/cpp/tests/golden/std_sprocket_unbored.json @@ -20,8 +20,8 @@ 27.35284634516466, 5.0 ], - "edges": 99, - "faces": 35, + "edges": 72, + "faces": 26, "tags": [ "solid.boolean.cut" ], diff --git a/src/cpp/tests/python/test_ops_features_booleans.py b/src/cpp/tests/python/test_ops_features_booleans.py index 8a5face3..03d27bb9 100644 --- a/src/cpp/tests/python/test_ops_features_booleans.py +++ b/src/cpp/tests/python/test_ops_features_booleans.py @@ -119,5 +119,5 @@ 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 (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.cut_rsolid) == ["solids", "skip_non_intersecting", "tracking_policy", "on_split", "clean"] assert parameter_names(scad.boolean_rsolidlist) == ["left", "right", "operation"] From afb63fc3753b12941c294c80b4932967a37d6955 Mon Sep 17 00:00:00 2001 From: Ni Jingzhe <1448976235@qq.com> Date: Thu, 12 Nov 2026 14:47:57 +0800 Subject: [PATCH 32/32] release: 2.3.0 (includes the never-published 2.2.9) DECISION: release the one-solid Part, the Windows build fix and the three issue fixes (#87, #88, #89) as 2.3.0. Not 2.2.10: the owner chose 2.3.0. 2.2.9 is skipped; it was prepared on dev but never tagged or published (no v2.2.9 tag, nothing on PyPI, the dry run of its workflow published nothing), so there is nothing to withdraw. WHY A MINOR VERSION - It removes interface of the withdrawn 2.2.8 (`bodies=`, `Part.multi_body`, profile 3), adds keywords (`clean=` on three booleans), and widens the exporters' input. - It changes a default: `cut_rsolid`, `cut_rsolid_list` and `intersect_rsolid` unify their result, so the topology and the fingerprint of a body that used to keep a split face change. - It breaks a recorded format on purpose: `clean` is a required parameter of the three recorded operations, a graph recorded before 2.3.0 is rejected on replay and has to be recorded again (the owner's rule: no branches for old formats). WHAT CHANGES - pyproject.toml, uv.lock (the simplecadapi package entry), docs/skill/SKILL.md and docs/skill/references/SDK_OVERVIEW.md: 2.2.9 -> 2.3.0. - CHANGELOG.md: the 2.2.9 entry becomes 2.3.0 (2026-11-12) and gains the four changes; "Fixed" gains the blend validity check. - docs/updates/2.3.0.md and 2.3.0.zh-CN.md replace the 2.2.9 notes (deleted): everything the 2.2.9 notes said, then the booleans, the exporters and read-only runs, the fingerprints, and the Windows build. The release workflow turns them into the release page. Checked: test_skill_lint and test_auto_docs_gen pass; the release workflow checks that the tag equals the pyproject version (v2.3.0). test_skill_build::test_files_without_directives_copied_verbatim fails on the base commit as well (a Windows encoding problem of this machine). The code of dev was green in CI at 8c482366 before this commit; this commit changes no code. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 22 ++++++- docs/skill/SKILL.md | 4 +- docs/skill/references/SDK_OVERVIEW.md | 4 +- docs/updates/2.2.9.md | 50 -------------- docs/updates/2.2.9.zh-CN.md | 44 ------------- docs/updates/2.3.0.md | 93 +++++++++++++++++++++++++++ docs/updates/2.3.0.zh-CN.md | 79 +++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 198 insertions(+), 102 deletions(-) delete mode 100644 docs/updates/2.2.9.md delete mode 100644 docs/updates/2.2.9.zh-CN.md create mode 100644 docs/updates/2.3.0.md create mode 100644 docs/updates/2.3.0.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 99366250..c99aad74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,27 @@ All notable changes to SimpleCADAPI are documented here. Per-version user-facing notes live in [`docs/updates/`](docs/updates/) (English and 中文). -## 2.2.9 — 2026-11-10 +## 2.3.0 — 2026-11-12 -Replaces 2.2.8, which was withdrawn (yanked on PyPI). User-facing notes: [`docs/updates/2.2.9.md`](docs/updates/2.2.9.md). +Follows 2.2.7. 2.2.8 was withdrawn (yanked on PyPI) and the 2.2.9 prepared to replace it was never published: both are in +2.3.0. User-facing notes: [`docs/updates/2.3.0.md`](docs/updates/2.3.0.md). ### Changed +- `cut_rsolid`, `cut_rsolid_list` and `intersect_rsolid` unify the same-domain faces and edges of their result by default + (`clean=True`, as `union_rsolid` always did; history and tags follow; the raw result is kept when the unified one would be + invalid; `clean=False` returns the raw result). A cut through the side of a box with a cylinder left the cylinder wall split + along its seam, and OCCT fillets an edge along such a face wrongly (#88). `clean` is a recorded parameter of the three + operations: a graph recorded before 2.3.0 has none and is rejected on replay (record it again). Bodies built with such a cut + have fewer faces and edges, and different fingerprints. +- The exporters (`export_product_package_to_step / stl / obj / mjcf`, the FreeCAD translator) also take the result of a build or + its definition, packaged in memory without the scene projection (#89): 1.85 s -> 0.26 s for a 36-hole plate. +- `run_notebook(path, cache=False)` writes nothing beside the notebook: no `deps.json`, no state directory, so it runs where the + source directory is read-only (#89). The skill allows a plain script for a task that asks for a single script (R3 exception). +- Geometry fingerprints quantize through a noise-tolerant `_q` (#87); a fingerprint of a body with a value on a quantization + boundary changes once, and a topology snapshot recorded before does not restore. +- The build backend is `tools/build_backend/scad_build.py` (scikit-build-core plus toolchain and OpenCASCADE detection on + Windows): a plain `uv sync` builds without Visual Studio or hand-set `CMAKE_*` variables; `uv` rebuilds only when `src/cpp` + changes. - 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 @@ -19,6 +35,8 @@ Replaces 2.2.8, which was withdrawn (yanked on PyPI). User-facing notes: [`docs/ makes the Part from the one you choose. Several solids as a deliverable are one Part each in an Assembly. ### Fixed +- `fillet_rsolid` / `chamfer_rsolid` raise when OCCT returns a solid it calls invalid while the input was valid (they used to + return it, #88); a fillet that fails on split faces says so instead of blaming the radius. - On Windows `sca cell` no longer kills the editor it talks to: the editor pid is probed without signalling it (`os.kill(pid, 0)` terminates the process on Windows). - Building from source with GCC 14 (`operators_promote.cpp` includes ``) and MinGW (`_USE_MATH_DEFINES`; `build_occt.py` diff --git a/docs/skill/SKILL.md b/docs/skill/SKILL.md index 2d26981c..08b8c925 100644 --- a/docs/skill/SKILL.md +++ b/docs/skill/SKILL.md @@ -4,9 +4,9 @@ description: Build, assemble, inspect, reconstruct, and export parametric CAD mo license: Apache-2.0 metadata: project: simplecadapi - version: 2.2.9 + version: 2.3.0 package-name: simplecadapi - package-version: 2.2.9 + package-version: 2.3.0 --- # SimpleCAD SDK Skill diff --git a/docs/skill/references/SDK_OVERVIEW.md b/docs/skill/references/SDK_OVERVIEW.md index bc93c570..50d42e5c 100644 --- a/docs/skill/references/SDK_OVERVIEW.md +++ b/docs/skill/references/SDK_OVERVIEW.md @@ -1,8 +1,8 @@ # SDK Overview - Project: `simplecadapi` -- Version: `2.2.9` -- Package distribution: `simplecadapi==2.2.9` +- Version: `2.3.0` +- Package distribution: `simplecadapi==2.3.0` ## What this skill bundles diff --git a/docs/updates/2.2.9.md b/docs/updates/2.2.9.md deleted file mode 100644 index ba8cf453..00000000 --- a/docs/updates/2.2.9.md +++ /dev/null @@ -1,50 +0,0 @@ -# 2.2.9 Update Notes - -2.2.9 replaces 2.2.8, which has been withdrawn (yanked on PyPI, its GitHub release deleted). 2.2.8 let a `Part` hold several -bodies, and a Part like that could not take part in an assembly. In 2.2.9 a Part holds exactly one solid again, and the -editor keeps what it needed from the change: several solids can sit side by side in a notebook while a model is built. -Everything else in 2.2.8 is in 2.2.9; the 2.2.8 notes describe it. - -## Why a Part holds one solid - -The editor has to let several solids exist in one notebook with no assembly constraint between them: to pattern a tool and then -cut, there is a moment where more than one tool and the body exist together. In 2.2.8 that was solved inside the Part. But a -Part is the unit an assembly places, mates and gives connectors to, and a Part of several bodies could do none of that: - -* connectors on it were refused, so no mate or public connector could reach it; -* `explode_rassembly` raised `TypeError` on any assembly that held one; -* the older scene compiler read the single body of a Part in four places. - -The solids side by side are intermediate values of a notebook, not product items. So they are notebook values now, and the -Part is one solid. The full account is in `design-docs/part-holds-one-solid.md`; every commit of the change states its own -reason. - -## What changes for you - -* **`make_part_rpart(part_id, body, name=None)`** takes one Solid, a `Body` (a solid with a display name, colour and opacity), or - a list of exactly one piece of a split cut. Several solids raise `ValueError`, which says what to do: one Part per solid in - an Assembly (identity placements when they only sit together), or union / cut them into one solid first. -* **Removed** (they only existed in the withdrawn 2.2.8): the `bodies=` argument, `Part.multi_body`, `scad.part_shape`, - `inspect_body_overlaps_rlist`, `BodyOverlap`, `BodyOverlapWarning`, and the part definition profile - `simplecad-part-definition-3` with `solid_cache.bodies`. A part definition is profile 2 with one solid, as before 2.2.8. - A model JSON recorded with several solids in one Part is refused on replay, with the same guidance. -* **A part notebook with one open solid** (`kind = "part"`, no cell making the Part) still gets the implicit Part. **With several - open solids** there is no product: running it raises `SeveralOpenBodiesError`, which names the solids; make the Part from the - one that is the part (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`), or make a Part of each and an Assembly - of them. -* **The editor** draws such open solids side by side and its header says "N open solids"; `use_as_product` makes the Part - from the solid you choose. One-body Parts, `Pieces` (the result of a splitting cut), connectors, mates and `explode_rassembly` - behave as they did. - -## Fixed - -* On Windows, `sca cell` no longer kills the editor it talks to: the editor's process id is probed without signalling it - (`os.kill(pid, 0)` on Windows terminates the process). -* Building from source: GCC 14 (`operators_promote.cpp` includes ``), MinGW (`_USE_MATH_DEFINES`, and - `build_occt.py` picks Ninja when a MinGW `g++` is the compiler), and the macOS wheels are built on `macos-15` (GitHub no - longer serves `macos-14`; the wheels still target macOS 14.0). - -## For contributors - -* `AGENT.md` records how to rebuild the C++ module on Windows and how commits are organised: one decision per commit, with the - reasons from the discussion that led to it. diff --git a/docs/updates/2.2.9.zh-CN.md b/docs/updates/2.2.9.zh-CN.md deleted file mode 100644 index a0617d58..00000000 --- a/docs/updates/2.2.9.zh-CN.md +++ /dev/null @@ -1,44 +0,0 @@ -# 2.2.9 更新说明 - -2.2.9 取代 2.2.8。2.2.8 已被撤回(PyPI 上已 yank,GitHub 上的发布也已删除)。2.2.8 让一个 `Part` 可以持有多个 body, -而这样的 Part 没法参与装配。2.2.9 让 Part 重新只持有一个实体,同时保留编辑器需要的能力:建模过程中,笔记本里可以并排放着 -几个实体。2.2.8 的其余内容都在 2.2.9 里,请看 2.2.8 的更新说明。 - -## 为什么 Part 只持有一个实体 - -编辑器必须允许一个笔记本里同时存在几个实体,而且它们之间不需要装配约束:比如先阵列刀具再切割,总有一刻刀具有好几把, -本体也在。2.2.8 把这件事放进了 Part 里解决。但是 Part 是装配里被摆放、被配合、被挂接口的单位,而持有多个 body 的 Part 这些都做不到: - -* 它不能带连接器,所以任何配合或公开连接器都够不到它; -* 装配里只要有一个这样的 Part,`explode_rassembly` 就报 `TypeError`; -* 旧的场景编译器有四处直接读 Part 的唯一 body。 - -并排的几个实体只是笔记本里的中间值,不是产品里的零件。所以现在它们是笔记本里的普通值,Part 就是一个实体。完整的说明在 -`design-docs/part-holds-one-solid.md`,这次改动的每个提交都写明了各自的原因。 - -## 对你有什么影响 - -* **`make_part_rpart(part_id, body, name=None)`** 接受一个实体、一个 `Body`(带显示名、颜色和不透明度的实体),或者切割分裂后 - 恰好只有一块的列表。给多个实体会报 `ValueError`,并说明该怎么做:每个实体做成一个 Part 放进装配(只是放在一起时用单位位姿), - 或者先把它们合并、切割成一个实体。 -* **已移除**(它们只在被撤回的 2.2.8 里存在过):`bodies=` 参数、`Part.multi_body`、`scad.part_shape`、 - `inspect_body_overlaps_rlist`、`BodyOverlap`、`BodyOverlapWarning`,以及零件定义的 `simplecad-part-definition-3` 版式和 - `solid_cache.bodies`。零件定义回到 2.2.8 之前的第 2 版式,只含一个实体。记录了“一个 Part 里有多个实体”的模型 JSON, - 重放时会被拒绝,并给出同样的提示。 -* **零件笔记本里只有一个开放实体**(`kind = "part"`,没有任何单元格做出 Part)时,仍然自动得到隐式 Part。**有多个开放实体** - 时没有产品:运行会报 `SeveralOpenBodiesError`,并列出这些实体;请用真正作为零件的那个来做 Part - (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`),或者给每个实体各做一个 Part,再放进装配。 -* **编辑器**会把这些开放实体并排画出来,标题栏显示“N 个开放实体”;`use_as_product` 会用你选的那个实体做出 Part。 - 单个实体的 Part、`Pieces`(切割分裂的结果)、连接器、配合和 `explode_rassembly` 的行为都和以前一样。 - -## 修复 - -* 在 Windows 上,`sca cell` 不会再杀掉它正在对话的编辑器:现在探测编辑器进程时不再发信号(Windows 上 - `os.kill(pid, 0)` 会直接终止进程)。 -* 从源码构建:GCC 14(`operators_promote.cpp` 补上 ``)、MinGW(定义 `_USE_MATH_DEFINES`,以及编译器是 MinGW 的 - `g++` 时 `build_occt.py` 选用 Ninja);macOS 的安装包改在 `macos-15` 上构建(GitHub 不再提供 `macos-14`,安装包仍然面向 - macOS 14.0)。 - -## 给贡献者 - -* `AGENT.md` 记录了在 Windows 上如何重新编译 C++ 模块,以及提交该怎么组织:一个决策一个提交,说明里写清楚促成这个决策的讨论和原因。 diff --git a/docs/updates/2.3.0.md b/docs/updates/2.3.0.md new file mode 100644 index 00000000..9cdbd2bb --- /dev/null +++ b/docs/updates/2.3.0.md @@ -0,0 +1,93 @@ +# 2.3.0 Update Notes + +2.3.0 follows 2.2.7. 2.2.8 was withdrawn (yanked on PyPI, its GitHub release deleted) and a 2.2.9 that replaced it was prepared +but never published, so everything of both is in 2.3.0. It is a minor version because it removes interface of the withdrawn +2.2.8, adds keywords, and changes what `cut_rsolid` and `intersect_rsolid` return by default. + +## A Part holds exactly one solid + +2.2.8 let a `Part` hold several bodies, and a Part like that could not take part in an assembly. In 2.3.0 a Part holds exactly +one solid again, and the editor keeps what it needed from the change: several solids can sit side by side in a notebook while a +model is built. + +The editor has to let several solids exist in one notebook with no assembly constraint between them: to pattern a tool and then +cut, there is a moment where more than one tool and the body exist together. In 2.2.8 that was solved inside the Part. But a +Part is the unit an assembly places, mates and gives connectors to, and a Part of several bodies could do none of that: + +* connectors on it were refused, so no mate or public connector could reach it; +* `explode_rassembly` raised `TypeError` on any assembly that held one; +* the older scene compiler read the single body of a Part in four places. + +The solids side by side are intermediate values of a notebook, not product items. So they are notebook values now, and the +Part is one solid. The full account is in `design-docs/part-holds-one-solid.md`. + +* **`make_part_rpart(part_id, body, name=None)`** takes one Solid, a `Body` (a solid with a display name, colour and opacity), or + a list of exactly one piece of a split cut. Several solids raise `ValueError`, which says what to do: one Part per solid in + an Assembly (identity placements when they only sit together), or union / cut them into one solid first. +* **Removed** (they only existed in the withdrawn 2.2.8): the `bodies=` argument, `Part.multi_body`, `scad.part_shape`, + `inspect_body_overlaps_rlist`, `BodyOverlap`, `BodyOverlapWarning`, and the part definition profile + `simplecad-part-definition-3` with `solid_cache.bodies`. A model JSON recorded with several solids in one Part is refused on + replay, with the same guidance. +* **A part notebook with one open solid** still gets the implicit Part. **With several open solids** there is no product: + running it raises `SeveralOpenBodiesError`, which names the solids; make the Part from the one that is the part + (`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`), or make a Part of each and an Assembly of them. +* **The editor** draws such open solids side by side and its header says "N open solids"; `use_as_product` makes the Part + from the solid you choose. + +## Booleans: `cut_rsolid` and `intersect_rsolid` clean their result + +`union_rsolid` always unified the faces and edges that lie on one surface; `cut_rsolid`, `cut_rsolid_list` and +`intersect_rsolid` did not. A cut through the side of a box with a cylinder leaves the cylinder wall split along its seam, and +OCCT fillets an edge along such a split face wrongly: it returned an invalid solid with a wrong volume and no error (issue #88), +or refused with a message that blamed the radius. + +* **`cut_rsolid`, `cut_rsolid_list`, `intersect_rsolid` take `clean=True`** (the default) and unify what they leave split. Tags + and lineage follow the clean-up. If the unified result would be invalid, the raw result is kept. `clean=False` returns the raw + result, as before. +* **Fewer faces and edges** in the result of a cut that used to leave a split face: the geometry (volume, area, bounding box) is + the same, the topology and so the fingerprints of such a body change. Rebuild stored products that contain one. +* **`fillet_rsolid` and `chamfer_rsolid` check their result**: a solid OCCT calls invalid (while the input was valid) is an + error naming the operation, not a returned value. +* **A fillet that fails on split faces says so** (and that the radius is not the limit) instead of advising a smaller radius. +* **Recorded graphs**: `clean` is a recorded parameter of `make_cut_rsolid`, `make_cut_rsolid_list` and `make_intersect_rsolid`. + A graph recorded before 2.3.0 has none and is rejected on replay like any graph with a missing parameter: record it again. + +## Exporting without a `.scadpkg` file, and running where the source is read-only + +* **The exporters take the result of a build.** `export_product_package_to_step / stl / obj / mjcf` and the FreeCAD translator + accept the result of `@scad.part` / `@scad.assemble` (or its definition) as well as a package, bytes or a path, and package + it in memory without the scene (the tessellated projection no exporter reads). A STEP file from a freshly built part: + `scad.exporter.export_product_package_to_step(build_part(), "part.step")`; the 120 x 120 x 10 plate of issue #89 takes 0.26 s + where capture followed by export took 1.85 s. +* **`run_notebook(path, cache=False)` leaves the source directory alone.** It no longer writes `__marimo__/.../deps.json` or + creates the state directory beside the notebook, so it runs where the source is read-only (a container with the source + mounted `:ro`). A run with the cache still writes its state there, and fails if it cannot. +* **A single-script task is a supported entry.** The skill keeps "a model is a marimo notebook" (R3) for everything a person + opens or reuses, and now allows a plain script for a task that asks for exactly that, with a template in + `domains/export-and-translation.md`. + +## Fingerprints do not split a value on a quantization boundary + +A body's geometry fingerprint quantizes every measured value to 1e-7 mm. A design value such as -3.34690815 mm lies exactly on a +boundary of that rounding, and one unit in the last place of computation noise (a BREP round trip) sent it to the neighbouring +integer, so the fingerprint changed and restoring a topology snapshot raised `geometry_mismatch` (issue #87). The quantizer now +snaps the value to a grid far coarser than the noise and far finer than a quantum before rounding. Fingerprints change once for a +body with a value on a boundary; a snapshot recorded before that does not restore and has to be captured again. + +## Fixed + +* On Windows, `sca cell` no longer kills the editor it talks to: the editor's process id is probed without signalling it + (`os.kill(pid, 0)` on Windows terminates the process). +* Building from source: GCC 14 (`operators_promote.cpp` includes ``), MinGW (`_USE_MATH_DEFINES`, and + `build_occt.py` picks Ninja when a MinGW `g++` is the compiler), and the macOS wheels are built on `macos-15` (the wheels still + target macOS 14.0). + +## For contributors + +* **A plain `uv sync` builds the package on Windows.** Without Visual Studio the build used to fall to the NMake generator and + fail, and every contributor set `CMAKE_GENERATOR`, `CC`, `CXX` and `CMAKE_ARGS` by hand. The build backend + (`tools/build_backend/scad_build.py`) now picks Ninja and the gcc on the path when Visual Studio is absent, finds OpenCASCADE + (`SCAD_OCCT_PREFIX`, `C:/occt`, or a per-user cache it builds once) and limits the jobs by memory. Anything you set yourself + still wins. `uv` rebuilds only when `src/cpp` changes (`[tool.uv] cache-keys`), not on a version bump. +* `AGENT.md` records how to rebuild the C++ module on Windows and how commits are organised: one decision per commit, with the + reasons from the discussion that led to it. diff --git a/docs/updates/2.3.0.zh-CN.md b/docs/updates/2.3.0.zh-CN.md new file mode 100644 index 00000000..dfb8a179 --- /dev/null +++ b/docs/updates/2.3.0.zh-CN.md @@ -0,0 +1,79 @@ +# 2.3.0 更新说明 + +2.3.0 接在 2.2.7 之后。2.2.8 已被撤回(PyPI 上已 yank,GitHub 上的发布也已删除),取代它的 2.2.9 已经准备好但从未发布, +所以两者的全部内容都在 2.3.0 里。这是一个次版本:它移除了被撤回的 2.2.8 里的接口,增加了关键字,并改变了 +`cut_rsolid` 和 `intersect_rsolid` 默认返回的结果。 + +## Part 只持有一个实体 + +2.2.8 让一个 `Part` 可以持有多个 body,而这样的 Part 没法参与装配。2.3.0 让 Part 重新只持有一个实体,同时保留编辑器需要的能力: +建模过程中,笔记本里可以并排放着几个实体。 + +编辑器必须允许一个笔记本里同时存在几个实体,而且它们之间不需要装配约束:比如先阵列刀具再切割,总有一刻刀具有好几把, +本体也在。2.2.8 把这件事放进了 Part 里解决。但是 Part 是装配里被摆放、被配合、被挂接口的单位,而持有多个 body 的 Part 这些都做不到: + +* 它不能带连接器,所以任何配合或公开连接器都够不到它; +* 装配里只要有一个这样的 Part,`explode_rassembly` 就报 `TypeError`; +* 旧的场景编译器有四处直接读 Part 的唯一 body。 + +并排的几个实体只是笔记本里的中间值,不是产品里的零件。所以现在它们是笔记本里的普通值,Part 就是一个实体。完整的说明在 +`design-docs/part-holds-one-solid.md`。 + +* **`make_part_rpart(part_id, body, name=None)`** 接受一个实体、一个 `Body`(带显示名、颜色和不透明度的实体),或者切割分裂后 + 恰好只有一块的列表。给多个实体会报 `ValueError`,并说明该怎么做:每个实体做成一个 Part 放进装配(只是放在一起时用单位位姿), + 或者先把它们合并、切割成一个实体。 +* **已移除**(它们只在被撤回的 2.2.8 里存在过):`bodies=` 参数、`Part.multi_body`、`scad.part_shape`、 + `inspect_body_overlaps_rlist`、`BodyOverlap`、`BodyOverlapWarning`,以及零件定义的 `simplecad-part-definition-3` 版式和 + `solid_cache.bodies`。记录了“一个 Part 里有多个实体”的模型 JSON,重放时会被拒绝,并给出同样的提示。 +* **零件笔记本里只有一个开放实体**时,仍然自动得到隐式 Part。**有多个开放实体**时没有产品:运行会报 `SeveralOpenBodiesError`, + 并列出这些实体;请用真正作为零件的那个来做 Part(`scad.make_part_rpart(part_id=scad.notebook_id(), body=...)`),或者给每个 + 实体各做一个 Part,再放进装配。 +* **编辑器**会把这些开放实体并排画出来,标题栏显示“N 个开放实体”;`use_as_product` 会用你选的那个实体做出 Part。 + +## 布尔运算:`cut_rsolid` 和 `intersect_rsolid` 会清理结果 + +`union_rsolid` 一直会把落在同一个曲面上的面和边合并;`cut_rsolid`、`cut_rsolid_list` 和 `intersect_rsolid` 以前不会。 +用圆柱从侧面切穿一个盒子,圆柱壁会沿着它的参数接缝被劈成两个面,而 OCCT 对沿着这种被劈开的面的边做圆角是错的: +它会返回体积不对的无效实体而且不报错(issue #88),或者拒绝,并把原因怪到半径上。 + +* **`cut_rsolid`、`cut_rsolid_list`、`intersect_rsolid` 增加 `clean=True`**(默认),把留下的被劈开的面和边合并。标签和血缘跟着 + 清理走。如果合并后的结果无效,就保留原始结果。`clean=False` 和以前一样返回原始结果。 +* **结果的面和边变少**:以前会留下被劈开的面的切割,几何(体积、面积、包围盒)不变,但拓扑变了,因此这种实体的指纹也变了。 + 包含这种切割的、已保存的产品需要重新生成。 +* **`fillet_rsolid` 和 `chamfer_rsolid` 会检查结果**:输入有效而 OCCT 判定结果无效时,会报出指明操作的错误,而不是把它当返回值交出去。 +* **圆角因面被劈开而失败时会直说**(并说明半径不是限制),不再建议更小的半径。 +* **记录的操作图**:`clean` 是 `make_cut_rsolid`、`make_cut_rsolid_list` 和 `make_intersect_rsolid` 的记录参数。2.3.0 之前记录的 + 图没有这个参数,重放时会和缺少任何参数的图一样被拒绝:请重新记录。 + +## 不经过 `.scadpkg` 文件直接导出,以及在源目录只读时运行 + +* **导出器接受构建的结果。** `export_product_package_to_step / stl / obj / mjcf` 和 FreeCAD 转换器除了包、字节和路径,也接受 + `@scad.part` / `@scad.assemble` 的结果(或它的定义),在内存里打包,且不生成场景(没有导出器会读那份网格投影)。 + 从刚建好的零件导出 STEP:`scad.exporter.export_product_package_to_step(build_part(), "part.step")`;issue #89 里那块 + 120 x 120 x 10 的板,先 capture 再导出要 1.85 秒,现在 0.26 秒。 +* **`run_notebook(path, cache=False)` 不碰源目录。** 它不再写 `__marimo__/.../deps.json`,也不在笔记本旁边创建状态目录,所以能在 + 源目录只读的环境里运行(比如源码以 `:ro` 挂载的容器)。带缓存的运行仍然把状态写在那里,写不进就报错。 +* **单脚本任务是受支持的入口。** 技能里“模型是 marimo 笔记本”(R3)仍然适用于人会打开或复用的一切,现在也允许任务明确要求单个脚本时 + 使用普通脚本,模板在 `domains/export-and-translation.md`。 + +## 指纹不会把落在量化边界上的值劈开 + +实体的几何指纹把每个测量值量化到 1e-7 mm。像 -3.34690815 mm 这样的设计值恰好落在这个取整的边界上,计算噪声(比如 BREP 往返) +差一个末位,就会被取整到相邻的整数,于是指纹变了,恢复拓扑快照时报 `geometry_mismatch`(issue #87)。现在量化器在取整前, +先把值吸附到一个比噪声粗得多、比量化单位细得多的网格上。带有边界上的值的实体,其指纹会变化一次;在此之前记录的快照无法恢复, +需要重新捕获。 + +## 修复 + +* 在 Windows 上,`sca cell` 不会再杀掉它正在对话的编辑器:现在探测编辑器进程时不再发信号(Windows 上 + `os.kill(pid, 0)` 会直接终止进程)。 +* 从源码构建:GCC 14(`operators_promote.cpp` 补上 ``)、MinGW(定义 `_USE_MATH_DEFINES`,以及编译器是 MinGW 的 + `g++` 时 `build_occt.py` 选用 Ninja);macOS 的安装包改在 `macos-15` 上构建(安装包仍然面向 macOS 14.0)。 + +## 给贡献者 + +* **在 Windows 上,直接 `uv sync` 就能构建。** 没有 Visual Studio 时,构建以前会落到 NMake 生成器而失败,每个贡献者都得手动设 + `CMAKE_GENERATOR`、`CC`、`CXX` 和 `CMAKE_ARGS`。现在构建后端(`tools/build_backend/scad_build.py`)在没有 Visual Studio 时选用 + Ninja 和 PATH 上的 gcc,自己找 OpenCASCADE(`SCAD_OCCT_PREFIX`、`C:/occt`,或者它一次性构建的用户缓存目录),并按内存限制并行数。 + 你自己设的值仍然优先。`uv` 只在 `src/cpp` 变化时才重新构建(`[tool.uv] cache-keys`),改版本号不会触发。 +* `AGENT.md` 记录了在 Windows 上如何重新编译 C++ 模块,以及提交该怎么组织:一个决策一个提交,说明里写清楚促成这个决策的讨论和原因。 diff --git a/pyproject.toml b/pyproject.toml index f8bdcd35..925732b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.2.9" +version = "2.3.0" description = "A simplified OpenCASCADE-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index a597e696..ba2aaeec 100644 --- a/uv.lock +++ b/uv.lock @@ -2996,7 +2996,7 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.2.9" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" },