From 3be1abe22796ee2661528ae2cb426c057e2f7c15 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 21:12:57 +0200 Subject: [PATCH 01/65] feat: zero-copy NGEOM stream buffers + IfcNgeomStream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StepNgeomStream.__next__ returns a capsule-owned read-only numpy uint8 array over the encoded buffer instead of nb::bytes — removes the one memcpy per solid crossing the C++/Python boundary (the consumer, adapy's lazy ShapeStore, retains the arriving object as-is). - tessellate_stream accepts any buffer-protocol object (bytes, memoryview, the stream's numpy arrays) via PyObject_GetBuffer, so a stored blob reaches the kernel with zero copies end-to-end. - New IfcNgeomStream: streaming per-product IFC -> NGEOM on the dep-free IfcResolver (no ifcopenshell/OCC). Yields (buffer, StepRootMeta) with meta.guid = GlobalId (new StepRootMeta field, empty on the STEP path); per-product cache clearing bounds memory; .unit_scale/.products_total/ .products_skipped expose file units and coverage. v1 carries no colour or spatial hierarchy (consumer fills those from its own IFC metadata). - IfcResolver: product_guid() + clear_cache() to support the stream. Co-Authored-By: Claude Fable 5 --- src/adacpp/cad/__init__.py | 2 + src/cad/cad_py_wrap.cpp | 97 ++++++++++++++++++++++++++++++++++++-- src/cad/ifc_reader.h | 15 ++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/adacpp/cad/__init__.py b/src/adacpp/cad/__init__.py index 30a265e..41517c3 100644 --- a/src/adacpp/cad/__init__.py +++ b/src/adacpp/cad/__init__.py @@ -35,6 +35,7 @@ stream_step_to_ngeom = _cad.stream_step_to_ngeom StepRootMeta = _cad.StepRootMeta StepNgeomStream = _cad.StepNgeomStream +IfcNgeomStream = _cad.IfcNgeomStream _step_index_parity = _cad._step_index_parity ifc_taxonomy_settings = _cad.ifc_taxonomy_settings meshopt_simplify_mesh = _cad.meshopt_simplify_mesh @@ -143,6 +144,7 @@ "step_parity", "StepRootMeta", "StepNgeomStream", + "IfcNgeomStream", "meshopt_simplify_mesh", "bbox", "obb", diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index d8c381b..827d080 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -385,15 +385,25 @@ Mesh tessellate_box_impl(float dx, float dy, float dz) { // GroupReference per root (node_id = root index in serialization order; adapy maps it back // to its own instance id). pipeline: "libtess2" (OCC-free neutral path) | "occ" | "cgal" // | "hybrid" (ifcopenshell taxonomy kernels). angular is in DEGREES. -Mesh tessellate_stream_impl(nb::bytes buffer, const std::string &pipeline, double deflection, double angular_deg, +Mesh tessellate_stream_impl(nb::object buffer, const std::string &pipeline, double deflection, double angular_deg, nb::dict settings, int threads) { using namespace adacpp::ngeom; + // Accept any buffer-protocol object (bytes, memoryview, the capsule-owned numpy + // arrays StepNgeomStream/IfcNgeomStream yield) so a lazy ShapeStore blob reaches + // the kernel with zero copies end-to-end. + Py_buffer view{}; + if (PyObject_GetBuffer(buffer.ptr(), &view, PyBUF_SIMPLE) != 0) { + PyErr_Clear(); + return Mesh(0, {}, {}); // not a buffer -> empty mesh + } NgeomDoc doc; try { - doc = decode(reinterpret_cast(buffer.c_str()), buffer.size()); + doc = decode(static_cast(view.buf), (size_t) view.len); } catch (const std::exception &) { + PyBuffer_Release(&view); return Mesh(0, {}, {}); // malformed buffer -> empty mesh } + PyBuffer_Release(&view); // decode copies what it keeps; the view is no longer needed // ifcopenshell ConversionSettings overrides (taxonomy paths only). Any // python scalar is stringified; the C++ side parses per the setting type. @@ -436,12 +446,23 @@ Mesh tessellate_stream_impl(nb::bytes buffer, const std::string &pipeline, doubl // roots (same order). struct StepRootMeta { std::string id; + std::string guid; // IFC GlobalId (IfcNgeomStream); empty on the STEP path bool has_color = false; std::array color{0.5f, 0.5f, 0.5f, 1.0f}; std::vector> transforms; // per-instance world matrices std::vector>> instance_paths; // per-instance (rep_id, name) levels }; +// Hand a just-encoded NGEOM buffer to Python WITHOUT the nb::bytes memcpy: move the vector to the +// heap and expose it as a capsule-owned read-only numpy uint8 array. The consumer (adapy's lazy +// ShapeStore) retains the arriving object as-is, so the buffer is allocated exactly once. +static nb::object ngeom_buffer_to_ndarray(std::vector &&buf) { + auto *heap = new std::vector(std::move(buf)); + nb::capsule owner(heap, [](void *p) noexcept { delete static_cast *>(p); }); + size_t shape[1] = {heap->size()}; + return nb::cast(nb::ndarray>(heap->data(), 1, shape, owner)); +} + // Native STEP -> NGEOM buffer + per-root metadata. Reads the .stp with the native C++ reader, // re-encodes the resolved neutral records to one NGEOM buffer (decodable by the adapy Python // deserializer into ada.geom.Geometry), and returns the per-root colour / transforms / assembly @@ -527,7 +548,7 @@ class StepNgeomStream { one.roots.push_back(std::move(root)); std::vector buf = encode(one); r_->clear_geom_cache(); - return nb::make_tuple(nb::bytes(reinterpret_cast(buf.data()), buf.size()), std::move(m)); + return nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); } PyErr_SetNone(PyExc_StopIteration); throw nb::python_error(); @@ -539,6 +560,62 @@ class StepNgeomStream { size_t cursor_ = 0; }; +// Streaming per-product IFC -> NGEOM: the IFC sibling of StepNgeomStream, built on the dep-free +// IfcResolver (no ifcopenshell, no OCC). One product is resolved + encoded per __next__ and the +// resolver's statement cache is cleared between products, so memory stays at the offset index plus +// a single product. Yields (one-root NGEOM buffer, StepRootMeta) with meta.guid = the product's +// IFC GlobalId. Geometry is in FILE units — apply .unit_scale (metres per unit) on the consumer +// side. Products the analytic resolver can't represent (tessellated face sets, mixed multi-solid +// reps) are skipped and counted in .products_skipped so the consumer can fall back per-file. +// NOTE v1 carries no colour (IfcStyledItem unresolved) and no spatial path (IfcRelAggregates / +// IfcRelContainedInSpatialStructure not walked) — geometry/guid/name/placement only. +class IfcNgeomStream { +public: + explicit IfcNgeomStream(const std::string &path) { + idx_ = std::make_unique(adacpp::step::StreamIndex::from_file(path)); + r_ = std::make_unique(*idx_); + roots_ = r_->proxy_roots(); + unit_scale_ = r_->unit_scale(); + } + double unit_scale() const { return unit_scale_; } + long products_total() const { return (long) roots_.size(); } + long products_skipped() const { return skipped_; } + nb::tuple next() { + using namespace adacpp::ngeom; + while (cursor_ < roots_.size()) { + long pid = roots_[cursor_++]; + NgeomRoot root = r_->resolve_product(pid); + std::string guid = r_->product_guid(pid); + r_->clear_cache(); // bounded memory: statement/surface caches don't grow across products + if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean) { + ++skipped_; + continue; + } + StepRootMeta m; + m.id = root.id; + m.guid = std::move(guid); + m.has_color = root.has_color; + m.color = {root.cr, root.cg, root.cb, root.ca}; + m.transforms = root.transforms; + m.instance_paths = root.instance_paths; + NgeomDoc one; + one.roots.push_back(std::move(root)); + std::vector buf = encode(one); + return nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); + } + PyErr_SetNone(PyExc_StopIteration); + throw nb::python_error(); + } + +private: + std::unique_ptr idx_; + std::unique_ptr r_; + std::vector roots_; + double unit_scale_ = 1.0; + long skipped_ = 0; + size_t cursor_ = 0; +}; + // Native STEP -> Mesh: stream the .stp with the native C++ reader (offset index + per-solid lazy // resolve, no OCC/Python), tessellate each solid as it streams, and append into ONE combined Mesh // with a GroupReference per root. Memory stays bounded on the parse side (the index + a single @@ -3610,6 +3687,7 @@ void cad_module(nb::module_ &m) { nb::class_(m, "StepRootMeta") .def_ro("id", &StepRootMeta::id) + .def_ro("guid", &StepRootMeta::guid) .def_ro("has_color", &StepRootMeta::has_color) .def_ro("color", &StepRootMeta::color) .def_ro("transforms", &StepRootMeta::transforms) @@ -3883,6 +3961,19 @@ void cad_module(nb::module_ &m) { .def("__iter__", [](nb::object self) { return self; }) .def("__next__", &StepNgeomStream::next); + nb::class_(m, "IfcNgeomStream") + .def(nb::init(), "path"_a, + "Streaming per-product IFC -> NGEOM (dep-free: no ifcopenshell, no OCC). Iterate to get " + "(one-root NGEOM buffer, StepRootMeta) per product with bounded memory; meta.guid is the " + "product GlobalId. Geometry is in FILE units — apply .unit_scale (metres per unit). " + "Unrepresentable products (tessellated/mixed reps) are skipped and counted in " + ".products_skipped. v1 carries no colour and no spatial hierarchy.") + .def("__iter__", [](nb::object self) { return self; }) + .def("__next__", &IfcNgeomStream::next) + .def_prop_ro("unit_scale", &IfcNgeomStream::unit_scale) + .def_prop_ro("products_total", &IfcNgeomStream::products_total) + .def_prop_ro("products_skipped", &IfcNgeomStream::products_skipped); + m.def("_step_index_parity", &step_index_parity_impl, "path"_a, "Debug: build the STEP offset index via mmap scan and via the wasm-safe pread scan, returning " "a dict of per-field equality (ids/offs/roots/units/styled/absr/srr/cdsr/sdr)."); diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 1e34650..0edd9ba 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -171,6 +171,21 @@ class IfcResolver { return root; } + // The product's IFC GlobalId (arg 0 of any rooted entity). Empty when unparsable. + std::string product_guid(long pid) { + const Instance *p = inst(pid); + if (p && !p->args.empty() && p->args[0].kind == adacpp::step::Kind::Str) + return std::string(p->args[0].s); + return {}; + } + + // Drop the statement/surface caches — called between products by the streaming + // per-product consumer (IfcNgeomStream) so memory stays bounded on large files. + void clear_cache() { + cache_.clear(); + surf_cache_.clear(); + } + private: const StreamIndex &idx_; std::unordered_map> cache_; From bfd96457b1e8207e82cb1683540c9d98685e624f Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 3 Jul 2026 21:22:07 +0200 Subject: [PATCH 02/65] fix: shrink NGEOM stream buffers before handing to Python The moved encode() vectors carry growth-doubling capacity slack that the lazy ShapeStore would retain long-term (~+60 MB across the crane's 7291 blobs). shrink_to_fit trades one bounded realloc at yield time for an exact-size resident buffer. Co-Authored-By: Claude Fable 5 --- src/cad/cad_py_wrap.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 827d080..ed0512e 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -458,6 +458,11 @@ struct StepRootMeta { // ShapeStore) retains the arriving object as-is, so the buffer is allocated exactly once. static nb::object ngeom_buffer_to_ndarray(std::vector &&buf) { auto *heap = new std::vector(std::move(buf)); + // The encoder's growth-doubling leaves real capacity slack (~20% across the crane's + // 7291 blobs); the consumer retains these long-term, so trade one bounded realloc + // now for an exact-size resident buffer. Still ahead of nb::bytes: no slack case + // reallocates nothing, and the vector+copy never coexist with a PyBytes duplicate. + heap->shrink_to_fit(); nb::capsule owner(heap, [](void *p) noexcept { delete static_cast *>(p); }); size_t shape[1] = {heap->size()}; return nb::cast(nb::ndarray>(heap->data(), 1, shape, owner)); From 6ce57fb3a54c2f64c77361a688e13a7dfa1de632 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 07:19:08 +0200 Subject: [PATCH 03/65] fix: IfcNgeomStream must not yield procedural roots as empty buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encoder::encode carries root faces only; a product resolving to an extrusion/revolve/boolean (e.g. a mapped extruded solid) encoded as an EMPTY connected-face-set — the consumer saw silently-empty geometry ('shell model has no faces' in the audit). Skip + count such products so the consumer's kernel fallback handles them, until the encoder learns tags 50-52. Co-Authored-By: Claude Fable 5 --- src/cad/cad_py_wrap.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index ed0512e..8c38712 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -592,7 +592,13 @@ class IfcNgeomStream { NgeomRoot root = r_->resolve_product(pid); std::string guid = r_->product_guid(pid); r_->clear_cache(); // bounded memory: statement/surface caches don't grow across products - if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean) { + if (root.faces.empty()) { + // No face-set geometry. NOTE this also skips procedural roots + // (extrusion/revolve/boolean, e.g. a mapped extruded solid): + // Encoder::encode carries faces only, so yielding such a root + // would hand the consumer a silently-EMPTY buffer. Count it + // skipped and let the consumer's kernel fallback handle the + // product until the encoder learns tags 50-52. ++skipped_; continue; } From af055b0332dd0ee3e10639cee8fc1f066295deb0 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 4 Jul 2026 12:04:28 +0200 Subject: [PATCH 04/65] feat: [STEPPROF] coverage for all emit legs + machine-readable summary - StepProfiler phase hooks in stream_step_to_ifc (serial + parallel), stream_step_to_step, stream_ifc_to_step, and the StepNgeomStream / IfcNgeomStream iterators (scan_index / metadata / emit or iterate phases, per-solid stats, per-thread utilisation on the parallel legs; the streams also report their pure-C++ resolve+encode share vs the consumer-inclusive iterate window). - Every hook is zero-cost when ADACPP_STEP_PROFILE is unset: profiler methods early-return on one bool, and per-thread/per-call clock reads are guarded behind prof.on(). - The destructor now also emits ONE [STEPPROF-JSON] line (locale-safe numbers) so a log-capturing consumer can parse phases/peak/parallelism into structured metrics without scraping the pretty output. Co-Authored-By: Claude Fable 5 --- src/cad/cad_py_wrap.cpp | 98 ++++++++++++++++++++++++++++++-- src/geom/neutral/ngeom_profile.h | 61 ++++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 8c38712..0c74f5c 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -526,16 +526,34 @@ nb::dict step_index_parity_impl(const std::string &path) { // heap so the resolver's index pointer stays valid even if Python moves the object. class StepNgeomStream { public: - explicit StepNgeomStream(const std::string &path) { + explicit StepNgeomStream(const std::string &path) : prof_("step_ngeom_stream") { idx_ = std::make_unique(adacpp::step::StreamIndex::from_file(path)); + prof_.phase("scan_index"); r_ = std::make_unique(*idx_); r_->build_metadata(idx_->lists); // Single-threaded per-solid streaming → safe to bound parse_cache_ on giant shells (the // 67 MB single solid in 469826); the multi-threaded mesh/glb path must NOT (race on re-parse). r_->enable_parse_cache_bounding(); + prof_.phase("metadata"); + } + ~StepNgeomStream() { + // The iterate phase spans the whole consumption window (incl. the Python + // consumer's time between __next__ calls); resolve_encode_ms is the pure + // C++ share, so the gap between them is the consumer's own cost. + if (prof_.on()) { + prof_.phase("iterate(incl_consumer)"); + prof_.note("resolve_encode_ms", work_ms_); + } } nb::tuple next() { using namespace adacpp::ngeom; + // Zero cost when profiling is off: one bool test, no clock reads. + const bool on = prof_.on(); + auto t0 = on ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + auto charge = [&] { + if (on) + work_ms_ += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + }; const auto &roots = idx_->lists.roots; while (cursor_ < roots.size()) { NgeomRoot root = r_->resolve_root(roots[cursor_++]); @@ -543,6 +561,7 @@ class StepNgeomStream { r_->clear_geom_cache(); continue; } + prof_.solid(root.faces.size()); StepRootMeta m; m.id = root.id; m.has_color = root.has_color; @@ -553,13 +572,18 @@ class StepNgeomStream { one.roots.push_back(std::move(root)); std::vector buf = encode(one); r_->clear_geom_cache(); - return nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); + nb::tuple out = nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); + charge(); + return out; } + charge(); PyErr_SetNone(PyExc_StopIteration); throw nb::python_error(); } private: + adacpp::prof::StepProfiler prof_; // declared first → destroyed last (summary sees the notes) + double work_ms_ = 0; std::unique_ptr idx_; std::unique_ptr r_; size_t cursor_ = 0; @@ -576,17 +600,33 @@ class StepNgeomStream { // IfcRelContainedInSpatialStructure not walked) — geometry/guid/name/placement only. class IfcNgeomStream { public: - explicit IfcNgeomStream(const std::string &path) { + explicit IfcNgeomStream(const std::string &path) : prof_("ifc_ngeom_stream") { idx_ = std::make_unique(adacpp::step::StreamIndex::from_file(path)); + prof_.phase("scan_index"); r_ = std::make_unique(*idx_); roots_ = r_->proxy_roots(); unit_scale_ = r_->unit_scale(); + prof_.phase("metadata"); + } + ~IfcNgeomStream() { + if (prof_.on()) { + prof_.phase("iterate(incl_consumer)"); + prof_.note("resolve_encode_ms", work_ms_); + prof_.note("products_skipped", (double) skipped_); + } } double unit_scale() const { return unit_scale_; } long products_total() const { return (long) roots_.size(); } long products_skipped() const { return skipped_; } nb::tuple next() { using namespace adacpp::ngeom; + // Zero cost when profiling is off: one bool test, no clock reads. + const bool on = prof_.on(); + auto t0 = on ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + auto charge = [&] { + if (on) + work_ms_ += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + }; while (cursor_ < roots_.size()) { long pid = roots_[cursor_++]; NgeomRoot root = r_->resolve_product(pid); @@ -602,6 +642,7 @@ class IfcNgeomStream { ++skipped_; continue; } + prof_.solid(root.faces.size()); StepRootMeta m; m.id = root.id; m.guid = std::move(guid); @@ -612,13 +653,18 @@ class IfcNgeomStream { NgeomDoc one; one.roots.push_back(std::move(root)); std::vector buf = encode(one); - return nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); + nb::tuple out = nb::make_tuple(ngeom_buffer_to_ndarray(std::move(buf)), std::move(m)); + charge(); + return out; } + charge(); PyErr_SetNone(PyExc_StopIteration); throw nb::python_error(); } private: + adacpp::prof::StepProfiler prof_; // declared first → destroyed last (summary sees the notes) + double work_ms_ = 0; std::unique_ptr idx_; std::unique_ptr r_; std::vector roots_; @@ -2907,11 +2953,14 @@ static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_pat long max_solids) { using namespace adacpp::ifc_emit; FileStats fs; + adacpp::prof::StepProfiler prof("stream_step_to_ifc(st)"); auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); adacpp::step::Resolver r(idx); r.build_metadata(idx.lists); r.enable_parse_cache_bounding(); fs.unit_scale = r.unit_scale(); + prof.phase("metadata"); std::FILE *fp = std::fopen(out_path.c_str(), "wb"); if (!fp) return fs; @@ -2932,6 +2981,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_pat break; adacpp::ngeom::NgeomRoot root = r.resolve_root(sid); ++fs.solids_in; + prof.solid(root.faces.size()); size_t before = proxies.size(); emit_solid_ifc(em, buf, root, sid, (uint64_t) fs.solids_in * 1000u, proxies, &proxy_paths); if (proxies.size() > before) @@ -2939,10 +2989,12 @@ static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_pat r.clear_geom_cache(); flush(false); } + prof.phase("resolve+emit"); emit_spatial_tree(buf, [&]() { return em.alloc_id(); }, proxies, proxy_paths); buf += "ENDSEC;\nEND-ISO-10303-21;\n"; flush(true); std::fclose(fp); + prof.phase("spatial_tree+write"); fs.geom = em.stats(); return fs; } @@ -3004,10 +3056,13 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin using namespace adacpp::ifc_emit; using adacpp::ngeom::NgeomRoot; FileStats fs; + adacpp::prof::StepProfiler prof("stream_step_to_ifc(par)"); auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); adacpp::step::Resolver master(idx); master.build_metadata(idx.lists); fs.unit_scale = master.unit_scale(); + prof.phase("metadata"); std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); if (max_solids > 0 && (long) roots.size() > max_solids) @@ -3023,6 +3078,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin for (size_t i = 0; i < cost.size(); ++i) roots[i] = cost[i].second; } + prof.phase("lpt_order"); const long K = 17; // ids #1..#17 are the shared header block (Project/Site/Building/Storey + rels) // Robust global id allocation: each solid is emitted with LOCAL ids (1..n), then a contiguous // block of n ids is reserved atomically and the solid's text is renumbered by the block base. @@ -3056,6 +3112,11 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin std::atomic solids_in{0}; auto worker = [&](int t) { + // All profiling costs vanish when ADACPP_STEP_PROFILE is unset: prof.solid() + // early-returns on one bool, and the per-thread timestamps are guarded here. + const bool prof_on = prof.on(); + auto w0 = prof_on ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + size_t w_solids = 0; adacpp::step::Resolver r(idx); r.copy_metadata_from(master); Lane &L = lanes[t]; @@ -3073,6 +3134,8 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin break; NgeomRoot root = r.resolve_root(roots[i]); solids_in.fetch_add(1, std::memory_order_relaxed); + prof.solid(root.faces.size()); + ++w_solids; // Emit with LOCAL ids starting ABOVE the shared header block (K+1..), so shared refs // (#1..#K) are distinguishable and left intact by renumber. Then reserve a contiguous // global block of n ids atomically and shift the solid's local ids into it. @@ -3111,6 +3174,9 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin flush(true); std::fclose(L.fp); L.fp = nullptr; + if (prof_on) + prof.thread_done( + t, std::chrono::duration(std::chrono::steady_clock::now() - w0).count(), w_solids); }; std::vector pool; pool.reserve(nth - 1); @@ -3119,6 +3185,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin worker(0); for (std::thread &th : pool) th.join(); + prof.phase("emit_lanes(parallel)"); // assemble: header + concat lanes (ids globally disjoint) + containment over all proxies. std::FILE *out_fp = std::fopen(out_path.c_str(), "wb"); @@ -3162,6 +3229,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin std::fwrite(foot, 1, std::strlen(foot), out_fp); std::fclose(out_fp); std::filesystem::remove_all(tdir); + prof.phase("assemble+write"); return fs; } @@ -3246,10 +3314,13 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa using adacpp::ngeom::NgeomRoot; using adacpp::step_emit::StepBrepEmitter; adacpp::ifc_emit::FileStats fs; + adacpp::prof::StepProfiler prof("stream_step_to_step"); auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); adacpp::step::Resolver master(idx); master.build_metadata(idx.lists); fs.unit_scale = master.unit_scale(); + prof.phase("metadata"); std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); if (max_solids > 0 && (long) roots.size() > max_solids) roots.resize(max_solids); @@ -3263,6 +3334,7 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa for (size_t i = 0; i < cost.size(); ++i) roots[i] = cost[i].second; } + prof.phase("lpt_order"); const long K = 13; std::atomic id_counter{K + 1}; int nth = num_threads > 0 ? num_threads : (int) adacpp::effective_concurrency(); @@ -3288,6 +3360,11 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa std::atomic next{0}; std::atomic solids_in{0}; auto worker = [&](int t) { + // All profiling costs vanish when ADACPP_STEP_PROFILE is unset: prof.solid() + // early-returns on one bool, and the per-thread timestamps are guarded here. + const bool prof_on = prof.on(); + auto w0 = prof_on ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; + size_t w_solids = 0; adacpp::step::Resolver r(idx); r.copy_metadata_from(master); Lane &L = lanes[t]; @@ -3305,6 +3382,8 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa break; NgeomRoot root = r.resolve_root(roots[i]); solids_in.fetch_add(1, std::memory_order_relaxed); + prof.solid(root.faces.size()); + ++w_solids; // One baked part per instance (or one identity part when flat). size_t ninst = root.transforms.empty() ? 1 : root.transforms.size(); bool any = false; @@ -3359,6 +3438,9 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa flush(true); std::fclose(L.fp); L.fp = nullptr; + if (prof_on) + prof.thread_done( + t, std::chrono::duration(std::chrono::steady_clock::now() - w0).count(), w_solids); }; std::vector pool; pool.reserve(nth - 1); @@ -3367,6 +3449,7 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa worker(0); for (std::thread &th : pool) th.join(); + prof.phase("emit_lanes(parallel)"); std::FILE *out_fp = std::fopen(out_path.c_str(), "wb"); if (!out_fp) { std::filesystem::remove_all(tdir); @@ -3398,6 +3481,7 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa std::fwrite(foot, 1, std::strlen(foot), out_fp); std::fclose(out_fp); std::filesystem::remove_all(tdir); + prof.phase("assemble+write"); return fs; } @@ -3560,7 +3644,9 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ using adacpp::ngeom::NgeomRoot; using adacpp::step_emit::StepBrepEmitter; adacpp::ifc_emit::FileStats fs; + adacpp::prof::StepProfiler prof("stream_ifc_to_step"); auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); if (!idx.ok()) return fs; adacpp::ifc_read::IfcResolver r(idx); @@ -3569,6 +3655,7 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ if (max_solids > 0 && (long) roots.size() > max_solids) roots.resize(max_solids); fs.products_total = (long) roots.size(); + prof.phase("metadata"); std::FILE *fp = std::fopen(out_path.c_str(), "wb"); if (!fp) return fs; @@ -3590,6 +3677,7 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ continue; } ++fs.solids_in; + prof.solid(root.faces.size()); size_t ninst = root.transforms.empty() ? 1 : root.transforms.size(); bool any = false; for (size_t k = 0; k < ninst; ++k) { @@ -3630,10 +3718,12 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ ++fs.solids_out; flush(false); } + prof.phase("resolve+emit"); const char *foot = "ENDSEC;\nEND-ISO-10303-21;\n"; buf += foot; flush(true); std::fclose(fp); + prof.phase("write_tail"); return fs; } diff --git a/src/geom/neutral/ngeom_profile.h b/src/geom/neutral/ngeom_profile.h index 68aeb08..990f4e0 100644 --- a/src/geom/neutral/ngeom_profile.h +++ b/src/geom/neutral/ngeom_profile.h @@ -198,12 +198,73 @@ class StepProfiler { } if (timing_ && !timed_.empty()) dump_solid_timing(); + + // Machine-readable sibling of the pretty lines above: ONE compact JSON line a + // log-capturing consumer (the adapy audit worker) can parse into structured + // metrics without scraping the human format. Keys mirror the printed fields. + { + std::string j = "{\"label\":\""; + j += label_; + j += "\",\"wall_ms\":" + fmt_num(wall); + j += ",\"peak_rss_mb\":" + fmt_num(hwm); + j += ",\"cpu_s\":" + fmt_num(cpu, 2); + j += ",\"parallelism\":" + fmt_num(wall_s > 0 ? cpu / wall_s : 0.0, 2); + j += ",\"vctx\":" + std::to_string(e.vctx - snap0_.vctx); + j += ",\"nvctx\":" + std::to_string(e.nvctx - snap0_.nvctx); + j += ",\"disk_read_mb\":" + fmt_num((e.read_bytes - snap0_.read_bytes) / 1e6); + j += ",\"majflt\":" + std::to_string(e.majflt - snap0_.majflt); + j += ",\"solids\":" + std::to_string(n_solids_); + j += ",\"tris\":" + std::to_string(total_tris_); + j += ",\"max_tris_solid\":" + std::to_string(max_tris_); + j += ",\"phases\":["; + for (size_t i = 0; i < phases_.size(); ++i) { + if (i) + j += ","; + j += "{\"name\":\""; + j += phases_[i].name; + j += "\",\"ms\":" + fmt_num(phases_[i].ms) + ",\"rss_mb\":" + fmt_num(phases_[i].rss_mb) + "}"; + } + j += "]"; + if (!notes_.empty()) { + j += ",\"notes\":{"; + for (size_t i = 0; i < notes_.size(); ++i) { + if (i) + j += ","; + j += "\""; + j += notes_[i].first; + j += "\":" + fmt_num(notes_[i].second); + } + j += "}"; + } + if (!threads_.empty()) { + j += ",\"threads\":["; + for (size_t i = 0; i < threads_.size(); ++i) { + if (i) + j += ","; + j += "{\"tid\":" + std::to_string(threads_[i].tid) + + ",\"solids\":" + std::to_string(threads_[i].solids) + + ",\"busy_ms\":" + fmt_num(threads_[i].busy_ms) + "}"; + } + j += "]"; + } + j += "}"; + std::fprintf(stderr, "[STEPPROF-JSON] %s\n", j.c_str()); + } } private: static double ms(clock::time_point a, clock::time_point b) { return std::chrono::duration(b - a).count(); } + // %.f without locale surprises — JSON must always use '.' decimals. + static std::string fmt_num(double v, int prec = 0) { + char b[64]; + std::snprintf(b, sizeof(b), "%.*f", prec, v); + for (char *p = b; *p; ++p) + if (*p == ',') + *p = '.'; + return std::string(b); + } struct Phase { const char *name; double ms; From 927a479c1cf2d9db431268ba26111a47212e99cf Mon Sep 17 00:00:00 2001 From: krande Date: Sun, 5 Jul 2026 21:40:00 +0200 Subject: [PATCH 05/65] perf: fix the degenerate-domain B-spline grid explosion + face-parallel monster solids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct monster-solid pathologies from the corpus audit profiles: 1. Valve Hall (65M tris, stream par-eff 1.19x): ONE 6-face solid took 114.7s and emitted 62.4M triangles. Its slit-bounded deg-7x7 B-spline patch has a degenerate u-domain (6.5e-8 wide); tessellate_unbounded clamped dv = min(v_step, du) across unrelated parameter scales -> nv = 7.8M grid rows. Full-domain B-splines now route through tessellate_uv_grid (knot-span grid + deflection refinement + the 300k budget): 114.7s -> 0.20s, 62.4M -> 4k deflection-correct triangles; the whole file 123s -> 5.4s stream, 65M -> 2.8M tris. The raw parameter grid stays for the closed quadrics, where u/v are both angles and the isotropy clamp is sound. 2. 469826 (one 61k-face solid busy 54s while the pool drains at 20s): tessellate_one_root's face-set arm gains an opt-in face-level pool (tp.threads > 1, >= 64 faces; per-face local meshes merged in face order, output identical to serial). Both stream pipelines split a tail-dominant prefix (faces >= 2048 AND >= total/nthreads — merely-large solids stay in the pool: routing them through the serialized phase-A cost the crane 26%) and run it first with every thread on one solid's faces. 469826 glb: 67.3s -> 45.5s, peak stream RSS 2.0GB -> 323MB, identical triangles. Munin crane regression-checked at identical params: 58.2s -> 59.3s stream (run noise), RSS 483 -> 485MB, solids 7291 = 7291, tris -250 of 26.05M (the deflection-correct unbounded fix), phase A correctly not engaged. Per-solid timing now rides along whenever ADACPP_STEP_PROFILE is on (two clock reads + 24 bytes per solid), and the machine-readable summary carries the 10 slowest solids (>= 100 ms) so an audit row names the monster solid without a local re-run. Co-Authored-By: Claude Fable 5 --- src/cad/step_to_glb_stream.h | 157 +++++++++++++++++--------- src/cad/step_to_mesh_stream.h | 93 ++++++++++----- src/geom/neutral/ngeom_profile.h | 31 ++++- src/geom/neutral/ngeom_tessellate.cpp | 42 ++++++- 4 files changed, 236 insertions(+), 87 deletions(-) diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 77c1f92..d4b5d2d 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -65,16 +65,35 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou // LPT scheduling: order roots heaviest-first (by a cheap face-count proxy, no geometry built) so // the big solids start while every thread is still busy — the dynamic queue then fills the tail // with small ones instead of one thread chewing a giant solid alone at the end. + // + // HUGE roots (face count >= threshold) go further: LPT can start them early, but a single + // 61k-face solid still pins ONE worker for the whole conversion (469826: one solid busy 54 s + // while the other threads idle after 20 s). Those roots — a prefix of the sorted order — are + // processed FIRST, one at a time, with tessellate_doc's face-level pool using every thread. std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); + size_t n_huge = 0; if (nthreads > 1) { + constexpr size_t HUGE_FACES = 2048; std::vector> cost; cost.reserve(roots.size()); - for (long sid : roots) - cost.emplace_back(master.solid_face_count(sid), sid); + size_t total_faces = 0; + for (long sid : roots) { + size_t fc = master.solid_face_count(sid); + total_faces += fc; + cost.emplace_back(fc, sid); + } master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); for (size_t i = 0; i < cost.size(); ++i) roots[i] = cost[i].second; + // Tail-dominance test, not absolute size: phase A serializes resolve between its + // face-pool bursts, so routing merely-large solids through it SLOWS a well-balanced + // file (crane: 7291 solids, many 2-4k faces, pool efficiency already 3.5x -> phase A + // cost it 26%). Only a root bigger than a thread's fair share of the whole file can + // outlast the pool no matter how LPT schedules it. + const size_t fair_share = total_faces / (size_t) nthreads; + while (n_huge < cost.size() && cost[n_huge].first >= HUGE_FACES && cost[n_huge].first >= fair_share) + ++n_huge; prof.phase("lpt_order"); } @@ -101,11 +120,87 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou std::deque lanes; for (int t = 0; t < nthreads; ++t) lanes.emplace_back(spill, t); - std::atomic next{0}; - // Each worker pulls roots off a shared counter (dynamic balancing handles the dense-solid - // long tail), resolving with its OWN caches + a copy of the shared metadata, into its lane. + std::atomic next{n_huge}; + const bool tprof = prof.on(); // gate ALL per-solid clock reads -> zero cost in prod + // One root: resolve with the caller's resolver, tessellate (``tpp.threads`` > 1 runs + // tessellate_doc's face-level pool), bake + spill into the caller's lane. Shared by the + // huge-prefix phase and the per-solid worker pool. + auto process_root = [&](adacpp::step::Resolver &r, adacpp::glb::GlbSpillWriter &lane, size_t i, + const TessParams &tpp) { + NgeomRoot root = r.resolve_root(roots[i]); + if (root.id.empty()) + return; + size_t fc = root.faces.size(); + NgeomDoc one; + one.roots.push_back(std::move(root)); + std::chrono::steady_clock::time_point tt0; + if (tprof) + tt0 = std::chrono::steady_clock::now(); + TessMesh tm = tessellate_doc(one, tpp); + if (tprof && prof.timing()) + prof.solid_timed( + roots[i], fc, + std::chrono::duration(std::chrono::steady_clock::now() - tt0).count()); + if (tm.indices.empty()) + return; + prof.solid(tm.indices.size() / 3); + const NgeomRoot &rr = one.roots[0]; + adacpp::glb::GlbSolid gs; + gs.positions = std::move(tm.positions); + gs.indices = std::move(tm.indices); + gs.color = {rr.cr, rr.cg, rr.cb, rr.ca}; // grey default when !has_color + gs.transforms = rr.transforms; + gs.id = rr.id; // fallback leaf name + // gid = the solid's own product name (last level of its assembly path); + // the writer names each placement gid / gid/k+1. Fall back to the solid name. + if (!rr.instance_paths.empty() && !rr.instance_paths[0].empty()) + gs.product_name = rr.instance_paths[0].back().second; + gs.instance_paths = rr.instance_paths; // all placements, parallel to transforms + // Convert the file's length unit (e.g. mm) to metres — adapy's default unit, + // and what the viewer assumes. Without this the GLB is e.g. 1000x oversized, + // which (besides looking wrong) defeats the viewer's edge-overlay spatial-hash + // vertex welding (only valid for coordinates within ~1e5 units) -> its weldMap + // overflows V8's 2^24 Map cap ("Map maximum size exceeded"). Scale local + // positions + each instance transform's translation; rotation is unitless. + const double usc = r.unit_scale(); + if (usc != 1.0) { + const float s = (float) usc; + for (float &p : gs.positions) + p *= s; + for (auto &M : gs.transforms) { + M[12] *= s; + M[13] *= s; + M[14] *= s; + } + } + lane.add(gs); // spilled to disk immediately + nwritten.fetch_add(1, std::memory_order_relaxed); + }; + // Phase A — the huge prefix, one root at a time BEFORE the pool starts: every thread + // works the same solid's faces (tessellate_doc face pool), so a lone 61k-face monster + // no longer pins one worker for the conversion's whole tail. + if (n_huge > 0) { + TessParams tph = tp; + tph.threads = nthreads; + adacpp::step::Resolver r0(idx); + r0.copy_metadata_from(master); + double busy_ms = 0; + std::chrono::steady_clock::time_point b0; + if (tprof) + b0 = std::chrono::steady_clock::now(); + for (size_t i = 0; i < n_huge; ++i) { + process_root(r0, lanes[0], i, tph); + r0.clear_geom_cache(); + } + if (tprof) + busy_ms = std::chrono::duration(std::chrono::steady_clock::now() - b0).count(); + prof.thread_done(-1, busy_ms, n_huge); // tid -1 = the huge-prefix phase + adacpp::mem_trim(); + } + // Phase B — each worker pulls the remaining roots off the shared counter (dynamic + // balancing handles the dense-solid long tail), resolving with its OWN caches + a copy + // of the shared metadata, into its lane. auto worker = [&](int t) { - const bool tprof = prof.on(); // gate ALL per-solid clock reads -> zero cost in prod adacpp::step::Resolver r(idx); r.copy_metadata_from(master); adacpp::glb::GlbSpillWriter &lane = lanes[t]; @@ -118,55 +213,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou std::chrono::steady_clock::time_point b0; if (tprof) b0 = std::chrono::steady_clock::now(); - NgeomRoot root = r.resolve_root(roots[i]); - if (!root.id.empty()) { - size_t fc = root.faces.size(); - NgeomDoc one; - one.roots.push_back(std::move(root)); - std::chrono::steady_clock::time_point tt0; - if (tprof) - tt0 = std::chrono::steady_clock::now(); - TessMesh tm = tessellate_doc(one, tp); - if (tprof && prof.timing()) - prof.solid_timed( - roots[i], fc, - std::chrono::duration(std::chrono::steady_clock::now() - tt0) - .count()); - if (!tm.indices.empty()) { - prof.solid(tm.indices.size() / 3); - const NgeomRoot &rr = one.roots[0]; - adacpp::glb::GlbSolid gs; - gs.positions = std::move(tm.positions); - gs.indices = std::move(tm.indices); - gs.color = {rr.cr, rr.cg, rr.cb, rr.ca}; // grey default when !has_color - gs.transforms = rr.transforms; - gs.id = rr.id; // fallback leaf name - // gid = the solid's own product name (last level of its assembly path); - // the writer names each placement gid / gid/k+1. Fall back to the solid name. - if (!rr.instance_paths.empty() && !rr.instance_paths[0].empty()) - gs.product_name = rr.instance_paths[0].back().second; - gs.instance_paths = rr.instance_paths; // all placements, parallel to transforms - // Convert the file's length unit (e.g. mm) to metres — adapy's default unit, - // and what the viewer assumes. Without this the GLB is e.g. 1000x oversized, - // which (besides looking wrong) defeats the viewer's edge-overlay spatial-hash - // vertex welding (only valid for coordinates within ~1e5 units) -> its weldMap - // overflows V8's 2^24 Map cap ("Map maximum size exceeded"). Scale local - // positions + each instance transform's translation; rotation is unitless. - const double usc = r.unit_scale(); - if (usc != 1.0) { - const float s = (float) usc; - for (float &p : gs.positions) - p *= s; - for (auto &M : gs.transforms) { - M[12] *= s; - M[13] *= s; - M[14] *= s; - } - } - lane.add(gs); // spilled to disk immediately - nwritten.fetch_add(1, std::memory_order_relaxed); - } - } + process_root(r, lane, i, tp); r.clear_geom_cache(); if (tprof) busy_ms += diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index 8def223..7181187 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -177,17 +177,30 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o int nthreads = num_threads > 0 ? num_threads : (int) adacpp::effective_concurrency(); - // LPT: heaviest solids first so big ones start while every thread is busy. + // LPT: heaviest solids first so big ones start while every thread is busy. HUGE roots + // (the sorted prefix with >= HUGE_FACES faces) go further: they are processed first, one + // at a time, with tessellate_doc's face-level pool — see step_to_glb_stream.h. std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); + size_t n_huge = 0; if (nthreads > 1) { + constexpr size_t HUGE_FACES = 2048; std::vector> cost; cost.reserve(roots.size()); - for (long sid : roots) - cost.emplace_back(master.solid_face_count(sid), sid); + size_t total_faces = 0; + for (long sid : roots) { + size_t fc = master.solid_face_count(sid); + total_faces += fc; + cost.emplace_back(fc, sid); + } master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); for (size_t i = 0; i < cost.size(); ++i) roots[i] = cost[i].second; + // Tail-dominance test — see step_to_glb_stream.h: only a root bigger than a thread's + // fair share of the whole file goes through the serialized face-parallel phase. + const size_t fair_share = total_faces / (size_t) nthreads; + while (n_huge < cost.size() && cost[n_huge].first >= HUGE_FACES && cost[n_huge].first >= fair_share) + ++n_huge; prof.phase("lpt_order"); } @@ -211,8 +224,53 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o std::deque lanes; for (int t = 0; t < nthreads; ++t) lanes.emplace_back(spill, t, fmt); - std::atomic next{0}; + std::atomic next{n_huge}; + // One root: resolve with the caller's resolver, tessellate (``tpp.threads`` > 1 runs the + // face-level pool), bake into the caller's lane. Shared by the huge-prefix phase and the pool. + auto process_root = [&](adacpp::step::Resolver &r, meshwrite::MeshLane &lane, size_t i, const TessParams &tpp) { + NgeomRoot root = r.resolve_root(roots[i]); + if (root.id.empty()) + return; + NgeomDoc one; + one.roots.push_back(std::move(root)); + TessMesh tm = tessellate_doc(one, tpp); + if (tm.indices.empty()) + return; + const NgeomRoot &rr = one.roots[0]; + const float usc = (float) r.unit_scale(); + const std::vector> &tfs = rr.transforms; + size_t ninst = tfs.empty() ? 1 : tfs.size(); + for (size_t k = 0; k < ninst; ++k) { + const std::array &M = tfs.empty() ? kIdentity : tfs[k]; + if (fmt == MeshFormat::OBJ) { + lane.add_instance(tm.positions, tm.indices, M, usc); // welded + } else { + for (size_t e = 0; e + 2 < tm.indices.size(); e += 3) { + float w0[3], w1[3], w2[3]; + meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e]], w0); + meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e + 1]], w1); + meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e + 2]], w2); + lane.facet(w0, w1, w2); + } + } + } + }; + + // Phase A — huge prefix, one root at a time with every thread on its faces. + if (n_huge > 0) { + TessParams tph = tp; + tph.threads = nthreads; + adacpp::step::Resolver r0(idx); + r0.copy_metadata_from(master); + for (size_t i = 0; i < n_huge; ++i) { + process_root(r0, lanes[0], i, tph); + r0.clear_geom_cache(); + } + adacpp::mem_trim(); + } + + // Phase B — per-solid worker pool over the rest. auto worker = [&](int t) { adacpp::step::Resolver r(idx); r.copy_metadata_from(master); @@ -222,32 +280,7 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o size_t i = next.fetch_add(1, std::memory_order_relaxed); if (i >= roots.size()) break; - NgeomRoot root = r.resolve_root(roots[i]); - if (!root.id.empty()) { - NgeomDoc one; - one.roots.push_back(std::move(root)); - TessMesh tm = tessellate_doc(one, tp); - if (!tm.indices.empty()) { - const NgeomRoot &rr = one.roots[0]; - const float usc = (float) r.unit_scale(); - const std::vector> &tfs = rr.transforms; - size_t ninst = tfs.empty() ? 1 : tfs.size(); - for (size_t k = 0; k < ninst; ++k) { - const std::array &M = tfs.empty() ? kIdentity : tfs[k]; - if (fmt == MeshFormat::OBJ) { - lane.add_instance(tm.positions, tm.indices, M, usc); // welded - } else { - for (size_t e = 0; e + 2 < tm.indices.size(); e += 3) { - float w0[3], w1[3], w2[3]; - meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e]], w0); - meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e + 1]], w1); - meshwrite::bake(M, usc, &tm.positions[3 * tm.indices[e + 2]], w2); - lane.facet(w0, w1, w2); - } - } - } - } - } + process_root(r, lane, i, tp); r.clear_geom_cache(); if (++local % 128 == 0) adacpp::mem_trim(); diff --git a/src/geom/neutral/ngeom_profile.h b/src/geom/neutral/ngeom_profile.h index 990f4e0..db89cc5 100644 --- a/src/geom/neutral/ngeom_profile.h +++ b/src/geom/neutral/ngeom_profile.h @@ -98,7 +98,14 @@ class StepProfiler { using clock = std::chrono::steady_clock; explicit StepProfiler(const char *label) : on_(std::getenv("ADACPP_STEP_PROFILE") != nullptr), - timing_(std::getenv("ADACPP_STEP_SOLID_TIMING") != nullptr), label_(label) { + // Per-solid timing rides along whenever profiling is on: the cost is two + // steady_clock reads + one 24-byte row per solid behind the mutex prof.solid() + // already takes — negligible against a solid's resolve+tessellate. It is what + // identifies the monster solid of a slow conversion without a local re-run; + // ADACPP_STEP_SOLID_TIMING still enables it standalone. + timing_(std::getenv("ADACPP_STEP_PROFILE") != nullptr || + std::getenv("ADACPP_STEP_SOLID_TIMING") != nullptr), + label_(label) { on_ = on_ || timing_; if (on_) { t0_ = last_ = clock::now(); @@ -247,6 +254,28 @@ class StepProfiler { } j += "]"; } + if (!timed_.empty()) { + // The 10 slowest solids (>= 100 ms — sub-100ms rows are never the story), so the + // audit's convert_meta names the monster solid without a local re-run. + std::vector order(timed_.size()); + for (size_t i = 0; i < order.size(); ++i) + order[i] = i; + std::sort(order.begin(), order.end(), + [&](size_t a, size_t b) { return timed_[a].ms > timed_[b].ms; }); + std::string rows; + size_t emitted = 0; + for (size_t r = 0; r < order.size() && emitted < 10; ++r) { + const SolidTime &ts = timed_[order[r]]; + if (ts.ms < 100.0) + break; + if (emitted++) + rows += ","; + rows += "{\"id\":" + std::to_string(ts.id) + ",\"faces\":" + std::to_string(ts.faces) + + ",\"ms\":" + fmt_num(ts.ms) + "}"; + } + if (!rows.empty()) + j += ",\"slowest_solids\":[" + rows + "]"; + } j += "}"; std::fprintf(stderr, "[STEPPROF-JSON] %s\n", j.c_str()); } diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index a150e45..c513f75 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -776,9 +776,18 @@ bool tessellate_unbounded(const Surface &s, const TessParams &tp, bool same_sens v1 = TWO_PI; } else if (const auto *b = dynamic_cast(&s)) { b->domain(u0, u1, v0, v1); + // Full-domain B-spline via the knot-span grid + deflection refinement (budgeted), + // NOT the raw parameter-step grid below: u/v parameter scales are unrelated, and + // `dv = min(v_step, du)` explodes when one span is degenerate. Real case (Valve + // Hall, a 6-face 'DC Voltage Divider' body): a slit-bounded deg-7x7 patch with + // u-domain 6.5e-8 wide clamped dv to 6.5e-8 over v-range 0.5 -> nv = 7.8M rows + // -> 62.4M triangles from ONE face, 114 s of a 123 s conversion. + return tessellate_uv_grid(s, u0, u1, v0, v1, tp, same_sense, mesh); } else { return false; } + // Angular parameter grid for the closed quadrics (u/v are both angles here, so the + // isotropy clamp is sound). double du = s.u_step(tp.deflection, tp.max_angle); double dv = std::min(s.v_step(tp.deflection, tp.max_angle), du); int nu = std::max(4, (int) std::ceil((u1 - u0) / du)); @@ -1773,6 +1782,35 @@ static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, Tes ++nnull; std::fprintf(stderr, "FDBG ROOT id=%s faces=%zu null=%zu\n", root.id.c_str(), root.faces.size(), nnull); } + // Face-parallel path for a single HUGE face-set root (tp.threads > 1): one + // 61k-face solid otherwise pins a lone worker for the whole conversion tail + // (469826: 54 s on one thread while the other three idle after 20 s). Faces + // are independent (each tessellate_face builds its own libtess2 tessellator); + // per-face local meshes merged in face order keep the output identical to + // the serial loop. The 64-face floor keeps small solids on the cheap path. + if (tp.threads > 1 && root.faces.size() >= 64) { + const size_t n = root.faces.size(); + std::vector locals(n); + std::atomic next{0}; + TessParams tpl = tp; + tpl.threads = 1; // no nested pools inside a face + unsigned nt = std::min((unsigned) tp.threads, (unsigned) n); + std::vector pool; + pool.reserve(nt - 1); + auto face_worker = [&]() { + for (size_t i = next.fetch_add(1); i < n; i = next.fetch_add(1)) + if (root.faces[i]) + tessellate_face(*root.faces[i], tpl, locals[i]); + }; + for (unsigned t = 1; t < nt; ++t) + pool.emplace_back(face_worker); + face_worker(); + for (std::thread &th : pool) + th.join(); + for (const TessMesh &lm : locals) + append_mesh(out, lm); + return; + } for (const auto &face : root.faces) if (face) tessellate_face(*face, tp, out); @@ -1802,12 +1840,14 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { std::vector locals(roots.size()); std::atomic next{0}; unsigned nthreads = std::min(want, (unsigned) roots.size()); + TessParams tp1 = tp; + tp1.threads = 1; // roots already saturate the pool — no nested face pools per root std::vector pool; pool.reserve(nthreads); for (unsigned t = 0; t < nthreads; ++t) pool.emplace_back([&]() { for (size_t i = next.fetch_add(1); i < roots.size(); i = next.fetch_add(1)) - tessellate_one_root(roots[i], tp, locals[i]); + tessellate_one_root(roots[i], tp1, locals[i]); }); for (std::thread &th : pool) th.join(); From 535c941ecd52973278fd903d8b2b3906ab046b25 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 6 Jul 2026 16:27:45 +0200 Subject: [PATCH 06/65] fix: tessellate curved shapes with a 0.2 rad angular deflection, not OCC's 0.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BRepMesh_IncrementalMesh defaulted to OCC's 0.5 rad (~28.6deg) angular deflection, so a large-radius arc facets into a handful of segments — the linear deflection alone can't keep it smooth because its sag tolerance scales with radius. A curved I-beam swept on a 7.25 m radius came out with ~7 arc segments (188 faces), visibly wrong; the bulge apex fell 2% short of the true extent. Pass angular=0.2 rad (~11.5deg) in all three interactive-tessellation BRepMesh calls (append_shape_triangles, its ShapeFix retry, and write_glb_bytes). The same beam now renders 404 faces with the apex matching ifcopenshell's ground-truth extent exactly. Cylinders/cones/spheres/pipes get the same smoothness; flat geometry is unaffected (no curvature to sample). Only the interactive object-tessellation path (IFC/SAT/FEM concept objects, the GLB writer) is touched — the native STEP→GLB stream kernel has its own angle_step density and is unchanged. Co-Authored-By: Claude Fable 5 --- src/cad/cad_py_wrap.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 0c74f5c..7fbf575 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -256,7 +256,12 @@ static void append_shape_triangles(const TopoDS_Shape &shape_in, double linear_d const double dz = zmax - zmin; linear_deflection = std::sqrt(dx * dx + dy * dy + dz * dz) * 0.05; } - BRepMesh_IncrementalMesh(shape, linear_deflection); + // Angular deflection 0.2 rad (~11.5deg), not OCC's loose 0.5 default: a large-radius + // arc (e.g. a curved beam swept on a 7 m radius) otherwise facets into a handful of + // segments — the linear deflection alone can't keep it smooth because the sag tolerance + // scales with radius. Caps the segment span so revolves/pipes/quadrics read as curved. + BRepMesh_IncrementalMesh(shape, linear_deflection, /*relative=*/Standard_False, + /*angular=*/0.2, /*parallel=*/Standard_True); // BRepMesh grids nothing when a face is missing its 2D p-curves (the parametric // representation it triangulates against) — common for imported B-reps: a bspline/NURBS @@ -278,7 +283,8 @@ static void append_shape_triangles(const TopoDS_Shape &shape_in, double linear_d const TopoDS_Shape fixed = sf.Shape(); if (!fixed.IsNull()) { shape = fixed; - BRepMesh_IncrementalMesh(shape, linear_deflection); + BRepMesh_IncrementalMesh(shape, linear_deflection, /*relative=*/Standard_False, + /*angular=*/0.2, /*parallel=*/Standard_True); } } } @@ -1047,9 +1053,11 @@ nb::bytes write_glb_bytes_impl(const ShapeHandle &sh, double linear_deflection) linear_deflection = 0.1; // RWGltf needs a triangulation per face; mesh in-place on the shape. + // Angular 0.2 rad (~11.5deg) rather than OCC's loose 0.5 default so large-radius + // arcs (curved beams, big pipes) stay smooth — see append_shape_triangles. BRepMesh_IncrementalMesh(shape, linear_deflection, /*relative=*/Standard_False, - /*angular=*/0.5, + /*angular=*/0.2, /*parallel=*/Standard_True); // Wrap the shape in a CAF document — RWGltf_CafWriter consumes one. From c713cec34941bd11493f77817aa92eb74b93dda8 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 6 Jul 2026 19:30:14 +0200 Subject: [PATCH 07/65] =?UTF-8?q?fix:=20NGEOM=20partial=20revolve=20?= =?UTF-8?q?=E2=80=94=20add=20end=20caps=20+=20guarantee=20outward=20windin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tessellate_revolve emitted only the side walls of a revolution and left partial sweeps (angle < 2pi) open, and it derived face normals from the profile loop's winding without normalising it — so a CW profile (e.g. an imported I-beam) tessellated inside-out (negative volume) and shaded dark. - Partial revolutions now cap the two open ends with the libtess2-triangulated profile at th=0 and th=ang (mirrors tessellate_sweep's start/end caps), so a swept beam is a closed solid, not an open tube. Full turns (cylinder/cone) close on themselves and are unchanged. - After emitting this revolve's triangle range, if its signed volume is negative the whole range is flipped (swap two verts + negate normals) so normals face out regardless of the incoming loop orientation. The check only fires on negative volume, so already-correct cylinders/cones are untouched. Pairs with the adapy serialize fix that feeds the angle in radians and the axis in the position-local frame; together the crane/corpus beam-revolved-solid.ifc tessellates correctly through the production libtess2 GLB path. Co-Authored-By: Claude Fable 5 --- src/geom/neutral/ngeom_tessellate.cpp | 56 +++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index c513f75..7d40639 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1588,11 +1588,19 @@ void tessellate_revolve(const RevolveN &rv, const TessParams &tp, Mesh &mesh) { const double ang = (rv.angle != 0.0) ? rv.angle : TWO_PI; std::vector ring; + std::vector> loops_uv; // all profile loops (outer + holes) for the end caps for (const FaceBoundN &b : rv.profile->bounds) { if (!b.loop) continue; - ring = b.loop->discretize(tp.deflection, tp.max_angle); - break; // outer loop; revolved profiles with holes are not expected + std::vector r = b.loop->discretize(tp.deflection, tp.max_angle); + std::vector uv; + uv.reserve(r.size()); + for (const Vec3 &p : r) + uv.push_back({p.x, p.y}); + if (uv.size() >= 3) + loops_uv.push_back(std::move(uv)); + if (ring.empty()) + ring = std::move(r); // outer loop drives the side walls } if (ring.size() > 1 && (ring.front() - ring.back()).norm() < 1e-12) ring.pop_back(); @@ -1617,6 +1625,7 @@ void tessellate_revolve(const RevolveN &rv, const TessParams &tp, Mesh &mesh) { R[j][i] = F.to_world(pr.x, pr.y, pr.z); } } + const auto cp0 = mesh.checkpoint(); // this revolve's triangle range starts here for (int j = 0; j < nseg; ++j) { const auto &A = R[j]; const auto &B = R[j + 1]; @@ -1626,8 +1635,47 @@ void tessellate_revolve(const RevolveN &rv, const TessParams &tp, Mesh &mesh) { emit_tri(mesh, A[i], B[i2], B[i]); } } - // NOTE: partial revolutions (angle < 2pi) would also need the two profile end caps; adapy's - // Cone/Cylinder use full revolutions, so they are not generated here yet. + + // Partial revolution (angle < 2pi): cap the two open ends with the triangulated + // profile at th=0 and th=ang, so a swept beam is a closed solid instead of an open + // tube. A full turn (cylinder/cone) closes on itself and needs no caps. Mirrors + // tessellate_sweep's start/end-cap handling (start reversed so its normal faces out). + if (ang < TWO_PI - 1e-6 && !loops_uv.empty()) { + auto Pr = [&](double th, double u, double v) -> Vec3 { + Vec3 pr = rotate_about({u, v, 0.0}, axo, axd, th); + return F.to_world(pr.x, pr.y, pr.z); + }; + Tess2Out cap = run_tess2(loops_uv, 1.0, 1.0); + if (cap.ok) { + for (const Tri &t : cap.tris) { + const Uv &u0 = cap.verts[t[0]], &u1 = cap.verts[t[1]], &u2 = cap.verts[t[2]]; + emit_tri(mesh, Pr(0.0, u0[0], u0[1]), Pr(0.0, u2[0], u2[1]), Pr(0.0, u1[0], u1[1])); + emit_tri(mesh, Pr(ang, u0[0], u0[1]), Pr(ang, u1[0], u1[1]), Pr(ang, u2[0], u2[1])); + } + } + } + + // Outward-facing normals: emit_tri derives each normal from its winding, so if the + // profile loop was CW the whole revolve comes out inside-out (negative signed volume) + // and shades dark. Flip every triangle in this revolve's range when that happens — + // handles walls + caps together regardless of the incoming loop orientation. + { + auto &pos = mesh.m.positions; + auto &nrm = mesh.m.normals; + const size_t vbeg = cp0[0] / 3, vend = pos.size() / 3; + auto Pv = [&](size_t k) -> Vec3 { return {pos[3 * k], pos[3 * k + 1], pos[3 * k + 2]}; }; + double sv = 0.0; + for (size_t k = vbeg; k + 2 < vend; k += 3) + sv += Pv(k).dot(Pv(k + 1).cross(Pv(k + 2))); + if (sv < 0.0) { + for (size_t k = vbeg; k + 2 < vend; k += 3) { + for (int c = 0; c < 3; ++c) + std::swap(pos[3 * (k + 1) + c], pos[3 * (k + 2) + c]); + for (int c = 0; c < 9; ++c) + nrm[3 * k + c] = -nrm[3 * k + c]; + } + } + } } // FixedReferenceSweptAreaSolid -> sweep the profile along a precomputed field of per-station frames From 3f0bc94808b535c3901422dcd53224309ad1ee8f Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 6 Jul 2026 21:54:44 +0200 Subject: [PATCH 08/65] =?UTF-8?q?fix:=20NGEOM=20extrusion=20=E2=80=94=20gu?= =?UTF-8?q?arantee=20outward=20winding=20(was=20inside-out,=20dark=20shadi?= =?UTF-8?q?ng)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tessellate_extrusion emitted side walls + caps whose winding assumed a CCW profile loop, but the discretized loop orientation isn't guaranteed — so a CW-outline profile (e.g. the IPE600 I-section of beam-extruded-solid.ifc, and plates) tessellated inside-out: negative signed volume, normals facing in, the solid shading dark. Same failure the revolve had. After emitting this extrusion's triangle range, flip every triangle when its signed volume is negative (swap two verts + negate normals), mirroring the tessellate_revolve guard. Only fires on negative volume, so already-correct extrusions are untouched. The beam/plate now match OCC's +volume; sweeps were already correct and are unaffected. Co-Authored-By: Claude Fable 5 --- src/geom/neutral/ngeom_tessellate.cpp | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 7d40639..3205b96 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1530,6 +1530,8 @@ void tessellate_extrusion(const ExtrusionN &ex, const TessParams &tp, Mesh &mesh const Frame &F = ex.frame; const Vec3 d = ex.direction * ex.depth; + const auto cp0 = mesh.checkpoint(); // this extrusion's triangle range starts here + std::vector> loops_uv; for (const FaceBoundN &b : ex.profile->bounds) { if (!b.loop) @@ -1571,6 +1573,28 @@ void tessellate_extrusion(const ExtrusionN &ex, const TessParams &tp, Mesh &mesh emit_tri(mesh, b0, t1, t0); } } + + // Outward-facing normals: emit_tri derives each normal from its winding, so if the profile + // loop's discretized orientation is CW the whole extrusion comes out inside-out (negative + // signed volume) and shades dark. Flip every triangle in this extrusion's range when that + // happens — same guard as tessellate_revolve, robust to the incoming loop orientation. + { + auto &pos = mesh.m.positions; + auto &nrm = mesh.m.normals; + const size_t vbeg = cp0[0] / 3, vend = pos.size() / 3; + auto Pv = [&](size_t k) -> Vec3 { return {pos[3 * k], pos[3 * k + 1], pos[3 * k + 2]}; }; + double sv = 0.0; + for (size_t k = vbeg; k + 2 < vend; k += 3) + sv += Pv(k).dot(Pv(k + 1).cross(Pv(k + 2))); + if (sv < 0.0) { + for (size_t k = vbeg; k + 2 < vend; k += 3) { + for (int c = 0; c < 3; ++c) + std::swap(pos[3 * (k + 1) + c], pos[3 * (k + 2) + c]); + for (int c = 0; c < 9; ++c) + nrm[3 * k + c] = -nrm[3 * k + c]; + } + } + } } // RevolvedAreaSolid -> sweep the profile boundary ring around (axis_origin, axis_dir) by angle. From 85de3312ad7037239df46c19438e5f37f849d222 Mon Sep 17 00:00:00 2001 From: krande Date: Tue, 7 Jul 2026 13:14:48 +0200 Subject: [PATCH 09/65] feat: per-surface size-adaptive angular tessellation density (TessParams.model_scale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed angular ceiling (max_angle) forces every curved surface to the same facet count regardless of size, so a dense assembly of tiny features (bolts/pins) blows the triangle budget while their facets are sub-pixel. New TessParams.model_scale (model bbox diagonal; 0 => OFF, backward compatible) makes the ceiling adaptive: angle_step -> adaptive_max_angle relaxes toward a coarse cap (~40deg) for surfaces whose radius is small relative to model_scale, while large visible surfaces keep the fine max_angle. Judged model-RELATIVE (not absolute), so a standalone small part stays fine and the same-radius feature inside a big assembly coarsens. model_scale is published per worker thread (tls_model_scale) from tessellate_one_root so the pure angle_step needs no signature change. Threaded through tessellate_stream / stream_step_to_glb / stream_step_to_mesh bindings (default 0.0). Crane (778 MB, 7291 solids) at base 10deg: 107M -> 53M tris (-51%), GLB 746 -> 375 MB — smaller than even the old global 20deg (73M), with large surfaces still at 10deg. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 19 ++++++++------- src/cad/step_to_glb_stream.h | 4 +++- src/cad/step_to_mesh_stream.h | 3 ++- src/geom/neutral/ngeom_surfaces.h | 34 +++++++++++++++++++++++++-- src/geom/neutral/ngeom_tessellate.cpp | 4 ++++ src/geom/neutral/ngeom_tessellate.h | 6 +++++ 6 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 7fbf575..275d0bd 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -392,7 +392,7 @@ Mesh tessellate_box_impl(float dx, float dy, float dz) { // to its own instance id). pipeline: "libtess2" (OCC-free neutral path) | "occ" | "cgal" // | "hybrid" (ifcopenshell taxonomy kernels). angular is in DEGREES. Mesh tessellate_stream_impl(nb::object buffer, const std::string &pipeline, double deflection, double angular_deg, - nb::dict settings, int threads) { + nb::dict settings, int threads, double model_scale) { using namespace adacpp::ngeom; // Accept any buffer-protocol object (bytes, memoryview, the capsule-owned numpy // arrays StepNgeomStream/IfcNgeomStream yield) so a lazy ShapeStore blob reaches @@ -424,6 +424,7 @@ Mesh tessellate_stream_impl(nb::object buffer, const std::string &pipeline, doub tp.deflection = deflection; tp.max_angle = angular_deg * 3.14159265358979323846 / 180.0; tp.threads = threads; // >1 => parallelise a root's faces (opt-in; default serial) + tp.model_scale = model_scale; // >0 => adaptive per-surface density (0 => fixed max_angle) tm = tessellate_doc(doc, tp); } else { // taxonomy kernels; accept "occ"/"cgal"/"hybrid" or "taxonomy-" @@ -729,17 +730,19 @@ Mesh stream_step_to_meshes_impl(const std::string &path, const std::string &pipe // now lives in step_to_glb_stream.h (adacpp::stream_step_to_glb) so the standalone OCC-free STP2GLB // CLI can reuse it without nanobind/OCCT. This thin wrapper keeps the existing python binding. int stream_step_to_glb_impl(const std::string &in_path, const std::string &out_path, double deflection, - double angular_deg, int num_threads, bool meshopt) { - return (int) adacpp::stream_step_to_glb(in_path, out_path, deflection, angular_deg, num_threads, meshopt); + double angular_deg, int num_threads, bool meshopt, double model_scale) { + return (int) adacpp::stream_step_to_glb(in_path, out_path, deflection, angular_deg, num_threads, meshopt, + /*spill_dir=*/"", model_scale); } // Threaded OCC-free STEP -> STL / OBJ (same reader + parallel tessellation as the GLB core, but bakes // world placements and streams triangles to a binary STL or Wavefront OBJ). Returns the triangle // count, or -1 on error. `fmt` is "stl" or "obj". long stream_step_to_mesh_impl(const std::string &in_path, const std::string &out_path, const std::string &fmt, - double deflection, double angular_deg, int num_threads) { + double deflection, double angular_deg, int num_threads, double model_scale) { adacpp::MeshFormat mf = (fmt == "obj" || fmt == "OBJ") ? adacpp::MeshFormat::OBJ : adacpp::MeshFormat::STL; - return adacpp::stream_step_to_mesh(in_path, out_path, mf, deflection, angular_deg, num_threads); + return adacpp::stream_step_to_mesh(in_path, out_path, mf, deflection, angular_deg, num_threads, + /*spill_dir=*/"", model_scale); } // GLB model diff: parse two GLBs into per-element summaries, match them, and emit colour ops keyed by @@ -3855,7 +3858,7 @@ void cad_module(nb::module_ &m) { "buffer. linear_deflection<=0 selects a per-shape bbox heuristic."); m.def("tessellate_stream", &tessellate_stream_impl, "buffer"_a, "pipeline"_a = "libtess2", "deflection"_a = 0.0, - "angular_deg"_a = 20.0, "settings"_a = nb::dict(), "threads"_a = 1, + "angular_deg"_a = 20.0, "settings"_a = nb::dict(), "threads"_a = 1, "model_scale"_a = 0.0, "Decode an NGEOM stream buffer (adapy ada.geom, neutral schema) and tessellate " "every instance into ONE combined Mesh with a GroupReference per root " "(node_id = root index). pipeline: 'libtess2' (OCC-free) | 'occ' | 'cgal' | " @@ -4023,7 +4026,7 @@ void cad_module(nb::module_ &m) { "wired for this path. angular_deg in degrees."); m.def("stream_step_to_glb", &stream_step_to_glb_impl, "in_path"_a, "out_path"_a, "deflection"_a = 0.0, - "angular_deg"_a = 20.0, "num_threads"_a = 0, "meshopt"_a = true, + "angular_deg"_a = 20.0, "num_threads"_a = 0, "meshopt"_a = true, "model_scale"_a = 0.0, "Native STEP -> GLB file: stream the .stp with the native reader (offset index + per-statement " "pread, bounded memory), tessellate each solid across num_threads worker threads (0 = auto = " "hardware_concurrency clamped to the cgroup cpu quota), each owning a spill lane joined at the " @@ -4033,7 +4036,7 @@ void cad_module(nb::module_ &m) { "solids written (-1 on I/O error). angular_deg in degrees."); m.def("stream_step_to_mesh", &stream_step_to_mesh_impl, "in_path"_a, "out_path"_a, "fmt"_a, "deflection"_a = 2.0, - "angular_deg"_a = 20.0, "num_threads"_a = 0, + "angular_deg"_a = 20.0, "num_threads"_a = 0, "model_scale"_a = 0.0, "Native STEP -> STL/OBJ file: the SAME native reader + parallel tessellation as " "stream_step_to_glb, but bakes each instance's world placement and streams triangles straight " "to a binary STL (fmt='stl') or Wavefront OBJ (fmt='obj'). Bounded memory; no Python round-trip. " diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index d4b5d2d..d1c110b 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -40,7 +40,8 @@ namespace adacpp { // caller can inspect the intermediate spill files. The lane temp files inside it are // still cleaned up by GlbSpillWriter's destructor; only the user-supplied dir survives. inline long stream_step_to_glb(const std::string &in_path, const std::string &out_path, double deflection, - double angular_deg, int num_threads, bool meshopt, const std::string &spill_dir = "") { + double angular_deg, int num_threads, bool meshopt, const std::string &spill_dir = "", + double model_scale = 0.0) { using namespace adacpp::ngeom; adacpp::prof::StepProfiler prof("stream_step_to_glb"); @@ -54,6 +55,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou TessParams tp; tp.deflection = deflection; tp.max_angle = angular_deg * 3.14159265358979323846 / 180.0; + tp.model_scale = model_scale; // >0 => adaptive per-surface density; the per-solid tpp copies inherit it // Metadata (colour/transform/path maps) once; workers copy these read-only maps. adacpp::step::Resolver master(idx); diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index 7181187..0f14d0c 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -157,7 +157,7 @@ inline void bake(const std::array &M, float usc, const float p[3], fl // Returns the number of triangles written, or -1 on I/O error. inline long stream_step_to_mesh(const std::string &in_path, const std::string &out_path, MeshFormat fmt, double deflection, double angular_deg, int num_threads, - const std::string &spill_dir = "") { + const std::string &spill_dir = "", double model_scale = 0.0) { using namespace adacpp::ngeom; static const std::array kIdentity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; adacpp::prof::StepProfiler prof("stream_step_to_mesh"); @@ -170,6 +170,7 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o TessParams tp; tp.deflection = deflection; tp.max_angle = angular_deg * 3.14159265358979323846 / 180.0; + tp.model_scale = model_scale; // >0 => adaptive per-surface density adacpp::step::Resolver master(idx); master.build_metadata(idx.lists); diff --git a/src/geom/neutral/ngeom_surfaces.h b/src/geom/neutral/ngeom_surfaces.h index 54e0b25..1f76d12 100644 --- a/src/geom/neutral/ngeom_surfaces.h +++ b/src/geom/neutral/ngeom_surfaces.h @@ -15,8 +15,38 @@ namespace adacpp::ngeom { +// Model bbox diagonal for the in-flight tessellation, published per-thread so the pure +// curvature function angle_step() can read it without threading the parameter through every +// Surface::u_step/v_step signature. 0 => adaptive density OFF (fixed max_angle). Set once per +// root by tessellate_one_root from TessParams::model_scale (constant across a whole call). +inline double &tls_model_scale() { + static thread_local double s = 0.0; + return s; +} + +// The angular ceiling actually applied for a surface of radius `r`. In adaptive mode +// (model_scale>0) the fixed `max_angle` is relaxed toward a coarse cap for surfaces small +// relative to the model: their facets are sub-pixel, so the chord-sag term already wants them +// coarse and we simply stop the fixed angle from forcing them fine. "Small" is judged against +// the model scale (NOT an absolute size or `defl`), so a standalone small part — where the +// surface is a large fraction of the whole model — stays fine, while the same-radius feature +// inside a large assembly coarsens. model_scale==0 returns max_angle unchanged. +inline double adaptive_max_angle(double r, double max_angle) { + double ms = tls_model_scale(); + if (ms <= 0.0 || r <= 1e-12) + return max_angle; + // Surfaces at/above ~1% of the model diagonal keep the fine angle; below that the ceiling + // ramps linearly toward a coarse cap (~40deg => >=9 facets around a full turn) as r shrinks. + const double r_ref = 0.01 * ms; + if (r >= r_ref) + return max_angle; + const double coarse_cap = std::max(max_angle, 40.0 * PI / 180.0); + double t = r / r_ref; // (0,1): smaller => more relaxed + return max_angle + (coarse_cap - max_angle) * (1.0 - t); +} + // Angular sampling step (radians) so a circular arc of radius r is approximated within chord -// sag `defl`, clamped to [2deg, max_angle]. Ported from geom.rs angle_step — the +// sag `defl`, clamped to [2deg, effective max_angle]. Ported from geom.rs angle_step — the // curvature-driven density that drives grid resolution on quadric/swept surfaces. inline double angle_step(double r, double defl, double max_angle) { if (r < 1e-12) @@ -24,7 +54,7 @@ inline double angle_step(double r, double defl, double max_angle) { double ratio = clampd(1.0 - defl / r, -1.0, 1.0); double a = 2.0 * std::acos(ratio); double lo = 2.0 * PI / 180.0; - double hi = std::max(max_angle, lo); + double hi = std::max(adaptive_max_angle(r, max_angle), lo); return clampd(a, lo, hi); } diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 3205b96..73a0716 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1831,6 +1831,10 @@ void append_mesh(TessMesh &dst, const TessMesh &src) { // Tessellate ONE root's geometry into `out` (no group bookkeeping — the caller records ranges). static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, TessMesh &out) { + // Publish the model scale for this worker thread so the curvature functions (angle_step -> + // adaptive_max_angle) can relax density on small features without a signature change. Constant + // for the whole call, so setting it per root (idempotent) is safe under the root/face pools. + tls_model_scale() = tp.model_scale; if (root.extrusion) { Mesh m(out); tessellate_extrusion(*root.extrusion, tp, m); diff --git a/src/geom/neutral/ngeom_tessellate.h b/src/geom/neutral/ngeom_tessellate.h index b4544f3..1137ce2 100644 --- a/src/geom/neutral/ngeom_tessellate.h +++ b/src/geom/neutral/ngeom_tessellate.h @@ -22,6 +22,12 @@ struct TessParams { // (serial) so callers already parallelising across roots/solids // (the STEP->GLB process pool) don't oversubscribe; a single // whole-model call (merge-preview generate) opts into all cores. + double model_scale = 0.0; // model bbox diagonal (world units). 0 => OFF: the fixed max_angle + // governs every surface (explicit-global-angle mode). >0 => ADAPTIVE: + // the angular ceiling is relaxed for surfaces whose radius is small + // relative to model_scale (imperceptible facets), so dense assemblies + // of tiny curved features (bolts/pins) don't blow the triangle budget + // while large visible surfaces keep the fine max_angle. }; struct TessMesh { From a9ad926f4c069be5b7609569c267210d09cf57e2 Mon Sep 17 00:00:00 2001 From: krande Date: Tue, 7 Jul 2026 21:53:23 +0200 Subject: [PATCH 10/65] fix: chain face-loop edges end-to-end + trim reversed B-spline boundary edges Two NGEOM tessellation bugs that made an ACIS flat plate (mixed straight + trimmed-intcurve boundary edges) collapse to a single triangle via libtess2: - LoopN::discretize concatenated the loop's edges in stored order assuming each already runs head-to-tail. ACIS loops mixing Line and trimmed-intcurve edges arrive with inconsistent per-edge direction/order, so blind concatenation produced a self-intersecting boundary and tess2 emitted 1 triangle. Re-chain edges by nearest endpoint (flipping an edge whose tail meets the running chain). For an already-consistent loop this is a no-op (same result). - align_polyline_to_vertices returned the FULL basis polyline when the edge ran against the sample direction (ia > ib) instead of the trimmed interior stretch, zig-zagging a trimmed straight B-spline edge back across its domain. Return the [ib, ia] stretch reversed so it still goes a -> b. adacpp suite green (62); adapy cadit green on the adacpp backend (357). --- src/geom/neutral/ngeom_topology.h | 82 ++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 6efa797..95ae14e 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -104,7 +104,17 @@ inline std::vector align_polyline_to_vertices(const std::vector &pts const size_t ia = nearest(a, pts), ib = nearest(b, pts); if (ia < ib) return std::vector(pts.begin() + ia, pts.begin() + ib + 1); - return pts; // vertices against the parameter direction: leave as-is + if (ia > ib) { + // Edge runs against the basis curve's sample direction: take the interior stretch + // [ib, ia] and reverse it so the result still goes a -> b. Returning the FULL polyline + // here made a trimmed straight B-spline edge (e.g. an ACIS intcurve boundary of a flat + // plate) zig-zag back across its own domain -> a self-intersecting face boundary that + // tess2 collapsed to a single triangle. + std::vector sub(pts.begin() + ib, pts.begin() + ia + 1); + std::reverse(sub.begin(), sub.end()); + return sub; + } + return std::vector{a, b}; // ia == ib: sub-range within one sample interval } // One oriented edge of a loop. `start`/`end` are the EDGE_CURVE endpoints with the ORIENTED_EDGE @@ -172,10 +182,19 @@ struct OrientedEdgeN { for (int i = 0; i <= useg; ++i) full.push_back(g->point(lo + (hi - lo) * i / useg)); pts = align_polyline_to_vertices(full, a, b); - if (pts.size() >= 2) { - pts.front() = a; - pts.back() = b; - } + // Loop assembly needs each edge to run start->end (the oriented topological + // endpoints). The a/b alignment + the common same_sense/orientation reversals below + // mis-ordered a trimmed intcurve boundary edge (an ACIS flat plate's B-spline side ran + // end->start), scrambling the face boundary into a self-intersecting loop that tess2 + // collapsed to one triangle. Emit start->end directly and return, bypassing those + // reversals (the align clip above already dropped the out-of-trim interior samples). + if (pts.size() < 2) + return {start, end}; + if ((pts.front() - start).norm() > (pts.back() - start).norm()) + std::reverse(pts.begin(), pts.end()); + pts.front() = start; + pts.back() = end; + return pts; } else { handled = false; } @@ -213,12 +232,61 @@ struct LoopN { std::vector discretize(double deflection, double max_angle) const { if (is_poly) return polygon; - std::vector out; + // Discretize each edge, then re-chain them end-to-end by nearest endpoint (flipping an edge + // when its tail, not its head, meets the running chain). Concatenating in stored order + // assumes every edge already runs head-to-tail, but ACIS loops of mixed Line / trimmed- + // intcurve edges can arrive with inconsistent per-edge direction (and order) — blind + // concatenation then self-intersects and tess2 collapses the face to a single triangle. + // For an already-consistent loop each next edge's head is the chain tail, so this reproduces + // the old plain concatenation (no reorder, no flip). + std::vector> segs; + segs.reserve(edges.size()); for (const auto &e : edges) { std::vector ep = e.discretize(deflection, max_angle); - for (const Vec3 &p : ep) { + if (ep.size() >= 2) + segs.push_back(std::move(ep)); + } + if (segs.empty()) + return {}; + + auto append = [](std::vector &out, const std::vector &s) { + for (const Vec3 &p : s) if (out.empty() || (p - out.back()).norm() > 1e-9) out.push_back(p); + }; + + std::vector used(segs.size(), 0); + std::vector out = segs[0]; + used[0] = 1; + for (size_t placed = 1; placed < segs.size(); ++placed) { + const Vec3 tail = out.back(); + size_t best = segs.size(); + bool flip = false; + double bd = std::numeric_limits::max(); + for (size_t i = 0; i < segs.size(); ++i) { + if (used[i]) + continue; + double df = (segs[i].front() - tail).norm(); + double db = (segs[i].back() - tail).norm(); + if (df < bd) { + bd = df; + best = i; + flip = false; + } + if (db < bd) { + bd = db; + best = i; + flip = true; + } + } + if (best == segs.size()) + break; + used[best] = 1; + if (flip) { + std::vector rev(segs[best].rbegin(), segs[best].rend()); + append(out, rev); + } else { + append(out, segs[best]); } } // drop a closing duplicate of the first vertex From b21fbb87109908d79fa562e1865f781f908b92c7 Mon Sep 17 00:00:00 2001 From: krande Date: Tue, 7 Jul 2026 23:48:29 +0200 Subject: [PATCH 11/65] fix: angle_step used the 2-degree floor (not max_angle) when deflection<=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the audit's angular-only mode (deflection=0), angle_step computed a = 2*acos(1 - 0/r) = 2*acos(1) = 0, then clamped it UP to the 2-degree lo floor — so every quadric (torus/cylinder/cone/sphere) was tessellated at 2 degrees, 5x finer than max_angle (~10-11 degrees). A boiler component of 44k analytic faces blew up to 43.7M triangles (a small R=13/r=3 torus alone hit 29,367 tris), rendering as a dense spiky mass. deflection<=0 means "no chord tolerance -> use the coarsest allowed step (max_angle, curvature-relaxed)", so return that. Torus face 29,367->1,640 tris; the solid 43.7M->7.5M. adacpp suite green (62); adapy cadit green on the adacpp backend (359). --- src/geom/neutral/ngeom_surfaces.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/geom/neutral/ngeom_surfaces.h b/src/geom/neutral/ngeom_surfaces.h index 1f76d12..a04744f 100644 --- a/src/geom/neutral/ngeom_surfaces.h +++ b/src/geom/neutral/ngeom_surfaces.h @@ -51,10 +51,16 @@ inline double adaptive_max_angle(double r, double max_angle) { inline double angle_step(double r, double defl, double max_angle) { if (r < 1e-12) return max_angle; - double ratio = clampd(1.0 - defl / r, -1.0, 1.0); - double a = 2.0 * std::acos(ratio); double lo = 2.0 * PI / 180.0; double hi = std::max(adaptive_max_angle(r, max_angle), lo); + // defl <= 0 means "angular-only, no chord-deflection constraint" — use the coarsest allowed + // step (max_angle, curvature-relaxed). The deflection formula gives a = 2*acos(1) = 0 for + // defl=0, which clamped UP to the 2-degree floor instead — so every quadric was tessellated at + // 2 degrees (5x finer than max_angle), e.g. a small torus blowing up to ~29k triangles. + if (defl <= 0.0) + return hi; + double ratio = clampd(1.0 - defl / r, -1.0, 1.0); + double a = 2.0 * std::acos(ratio); return clampd(a, lo, hi); } From bc590891a6ff60031570a99a77def221dcb45826 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 13:31:19 +0200 Subject: [PATCH 12/65] fix: IfcResolver honors IndexedPolyCurve arc segments (fillets no longer chorded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curve_points2d read only the raw CoordList of an IfcIndexedPolyCurve, ignoring the Segments list — so every IfcArcIndex collapsed to the chord between its listed points (sharp corners on filleted profiles). It now walks the typed [Keyword, ((indices))] segment pairs: IfcLineIndex is a polyline through all its points, IfcArcIndex a 3-point circular arc discretized via a new arc_poly_2d (circumcircle + swept interpolation, chord fallback when collinear) — matching the adapy Python reader's IfcArcIndex->ArcLine. Verified vs the ifcopenshell/Python oracle on beam-extruded-solid.ifc (I-section, 4 fillet arcs): C++ ifc->step profile now 77 points (was ~20 chords), cross-section area 2849.137 mm^2 == the Python analytic profile's 0.00284914 m^2 exactly. adacpp py+step suites green (62). First piece of the IfcResolver parity port (matching the Python IFC reader). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 70 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 0edd9ba..35fe379 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -632,6 +632,39 @@ class IfcResolver { } return p; } + // A 3-point (start, mid, end) circular arc in the z=0 plane, discretized to a polyline (matches + // the adapy Python reader's IfcArcIndex -> ArcLine, which the tessellator later rings). Falls + // back to the chord [a, m, b] when the three points are collinear (degenerate circumcircle). + static std::vector arc_poly_2d(const Vec3 &a, const Vec3 &m, const Vec3 &b) { + double ax = a.x, ay = a.y, mx = m.x, my = m.y, bx = b.x, by = b.y; + double d = 2.0 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my)); + if (std::abs(d) < 1e-12) + return {a, m, b}; + double a2 = ax * ax + ay * ay, m2 = mx * mx + my * my, b2 = bx * bx + by * by; + double ux = (a2 * (my - by) + m2 * (by - ay) + b2 * (ay - my)) / d; + double uy = (a2 * (bx - mx) + m2 * (ax - bx) + b2 * (mx - ax)) / d; + double r = std::hypot(ax - ux, ay - uy); + double ta = std::atan2(ay - uy, ax - ux); + double tm = std::atan2(my - uy, mx - ux); + double tb = std::atan2(by - uy, bx - ux); + auto wrap = [](double x) { + while (x < 0) + x += 2.0 * PI; + while (x >= 2.0 * PI) + x -= 2.0 * PI; + return x; + }; + double dab = wrap(tb - ta), dam = wrap(tm - ta); + double sweep = (dam <= dab) ? dab : dab - 2.0 * PI; // the direction a->b passing through m + int n = std::max(2, (int) std::ceil(std::abs(sweep) / (2.0 * PI) * 64.0)); + std::vector out; + out.reserve(n + 1); + for (int i = 0; i <= n; ++i) { + double t = ta + sweep * i / n; + out.push_back({ux + r * std::cos(t), uy + r * std::sin(t), 0}); + } + return out; + } // OuterCurve (IfcPolyline of IfcCartesianPoint, or IfcIndexedPolyCurve over an IfcCartesianPointList2D) // -> the profile's 2D polygon (z=0), drop the closing duplicate point. std::vector curve_points2d(long cid) { @@ -639,19 +672,52 @@ class IfcResolver { const Instance *in = inst(cid); if (!in) return pts; + auto push = [&](const Vec3 &p) { + if (pts.empty() || (pts.back() - p).norm() > 1e-9) + pts.push_back(p); + }; if (iequals(in->type, "IFCPOLYLINE")) { if (!in->args.empty() && in->args[0].is_list()) for (const Value &pr : in->args[0].items) if (pr.is_ref()) { Vec3 p = point(pr.i); - pts.push_back({p.x, p.y, 0}); + push({p.x, p.y, 0}); } } else if (iequals(in->type, "IFCINDEXEDPOLYCURVE")) { const Instance *pl = inst(ref_arg(*in, 0)); // IfcCartesianPointList2D(CoordList) + std::vector coords{{0, 0, 0}}; // 1-based: coords[0] is a placeholder if (pl && !pl->args.empty() && pl->args[0].is_list()) for (const Value &row : pl->args[0].items) if (row.is_list() && row.items.size() >= 2) - pts.push_back({row.items[0].as_double(), row.items[1].as_double(), 0}); + coords.push_back({row.items[0].as_double(), row.items[1].as_double(), 0}); + auto at = [&](long i1) -> Vec3 { + return (i1 >= 1 && (size_t) i1 < coords.size()) ? coords[i1] : Vec3{0, 0, 0}; + }; + // Segments (arg 1): a list of typed [Keyword, ((indices))] pairs. IfcLineIndex is a + // polyline through ALL its points; IfcArcIndex is a 3-point circular arc (discretized). + // Ignoring them (the old behaviour) collapsed every arc to the chord between listed + // points — sharp corners on filleted profiles. Absent Segments -> the raw coord polygon. + const Value *segs = (in->args.size() > 1 && in->args[1].is_list()) ? &in->args[1] : nullptr; + if (segs && !segs->items.empty()) { + for (size_t k = 0; k + 1 < segs->items.size(); k += 2) { + const Value &kw = segs->items[k]; + const Value &al = segs->items[k + 1]; + if (kw.kind != adacpp::step::Kind::Keyword || !al.is_list() || al.items.empty() || + !al.items[0].is_list()) + continue; + const std::vector &ix = al.items[0].items; + if (iequals(kw.s, "IFCARCINDEX") && ix.size() == 3) { + for (const Vec3 &q : arc_poly_2d(at(ix[0].i), at(ix[1].i), at(ix[2].i))) + push(q); + } else { + for (const Value &iv : ix) + push(at(iv.kind == adacpp::step::Kind::Int ? iv.i : (long) iv.as_double())); + } + } + } else { + for (size_t i = 1; i < coords.size(); ++i) + push(coords[i]); + } } if (pts.size() > 1 && (pts.front() - pts.back()).norm() < 1e-9) pts.pop_back(); From 1fa8f72c004a0f5e3918b7de2b6dea648221eafd Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 13:40:43 +0200 Subject: [PATCH 13/65] feat: NGEOM encoder emits analytic solids; IfcNgeomStream streams procedural IFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NGEOM Encoder emitted only CONNECTED_FACE_SET (a root's faces), so a faces-less procedural root (extrusion/revolve/boolean/sphere) encoded to an empty buffer — and IfcNgeomStream::next skipped every such root (the faces-only gate), dropping ALL analytic-solid IFC products to the kernel fallback. That was the core reason the pure-C++ IFC path yielded almost nothing. Teach encode() to emit tags 50-53 (extrusion/revolve/sphere + recursive boolean, layouts matching ngeom_decode.h and the adapy Python codec), and narrow the IfcNgeomStream gate to skip only roots with NO encodable geometry (empty faces AND not one of those solids — e.g. an alignment sweep). Boolean operands recurse (nested solids or shell operands). Verified: IfcNgeomStream on beam-extruded-solid.ifc now yields the extrusion (was 0 roots) and tessellates to bbox [-0.05,0,-0.1]..[0.05,1,0.1] == the ifcopenshell oracle exactly. adacpp ngeom+py+step suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 13 +++--- src/geom/neutral/ngeom_encode.h | 78 ++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 275d0bd..c3f73dd 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -639,13 +639,12 @@ class IfcNgeomStream { NgeomRoot root = r_->resolve_product(pid); std::string guid = r_->product_guid(pid); r_->clear_cache(); // bounded memory: statement/surface caches don't grow across products - if (root.faces.empty()) { - // No face-set geometry. NOTE this also skips procedural roots - // (extrusion/revolve/boolean, e.g. a mapped extruded solid): - // Encoder::encode carries faces only, so yielding such a root - // would hand the consumer a silently-EMPTY buffer. Count it - // skipped and let the consumer's kernel fallback handle the - // product until the encoder learns tags 50-52. + // The encoder now emits analytic solids (extrusion/revolve/boolean/sphere, tags 50-53) + // in addition to face sets, so a procedural root carries real geometry. Only a root with + // NO encodable geometry (empty faces + not one of those solids — e.g. an alignment sweep + // or an unsupported product) still yields nothing; skip it for the kernel fallback. + bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere; + if (root.faces.empty() && !has_solid) { ++skipped_; continue; } diff --git a/src/geom/neutral/ngeom_encode.h b/src/geom/neutral/ngeom_encode.h index 47004f1..b9d92e9 100644 --- a/src/geom/neutral/ngeom_encode.h +++ b/src/geom/neutral/ngeom_encode.h @@ -28,11 +28,20 @@ class Encoder { memo_.clear(); std::vector> roots; for (const NgeomRoot &root : doc.roots) { - std::vector body; - put_i32(body, (int) root.faces.size()); - for (const auto &f : root.faces) - put_i32(body, face(f)); - roots.emplace_back(add(tag::CONNECTED_FACE_SET, std::move(body)), root.id); + // Analytic solids (extrusion/revolve/boolean/sphere) encode to tags 50-53 so a faces-less + // procedural root carries real geometry — previously only CONNECTED_FACE_SET was emitted, + // so such a root became an empty buffer and the IfcNgeomStream consumer dropped it. + int gi = -1; + if (root.extrusion || root.revolve || root.boolean || root.sphere) + gi = solid_root(root); + if (gi < 0) { + std::vector body; + put_i32(body, (int) root.faces.size()); + for (const auto &f : root.faces) + put_i32(body, face(f)); + gi = add(tag::CONNECTED_FACE_SET, std::move(body)); + } + roots.emplace_back(gi, root.id); } std::vector out; const char *magic = "ADANGEOM"; @@ -92,6 +101,65 @@ class Encoder { put_v3(b, axis); return add(tag::PLACEMENT1, std::move(b)); } + // --- analytic solids (tags 50-53) — layouts match ngeom_decode.h + the adapy Python codec --- + int extrusion_rec(const std::shared_ptr &ex) { + std::vector b; + put_i32(b, face(ex->profile)); // profile FACE_SURFACE + put_i32(b, placement3(ex->frame)); + put_v3(b, ex->direction); + put_f64(b, ex->depth); + return add(tag::EXTRUDED_AREA_SOLID, std::move(b)); + } + int revolve_rec(const std::shared_ptr &rv) { + std::vector b; + put_i32(b, face(rv->profile)); + put_i32(b, placement3(rv->frame)); + put_i32(b, placement1(rv->axis_origin, rv->axis_dir)); // PLACEMENT1 axis + put_f64(b, rv->angle); + return add(tag::REVOLVED_AREA_SOLID, std::move(b)); + } + int sphere_rec(const std::shared_ptr &sp) { + std::vector b; + put_i32(b, placement3(sp->frame)); // centre placement + put_f64(b, sp->radius); + return add(tag::SPHERE_SOLID, std::move(b)); + } + int boolean_rec(const std::shared_ptr &bn) { + std::vector b; + put_i32(b, bn->op); + put_i32(b, solid_item_rec(bn->a)); + put_i32(b, solid_item_rec(bn->b)); + return add(tag::BOOLEAN_RESULT, std::move(b)); + } + // One boolean operand: nested solid, or a shell (CONNECTED_FACE_SET — solid_item_at reads it back + // as .faces). Sweep operands aren't emittable here (their baked-frame form is out of scope) -> -1. + int solid_item_rec(const SolidItemN &it) { + if (it.extrusion) + return extrusion_rec(it.extrusion); + if (it.revolve) + return revolve_rec(it.revolve); + if (it.boolean) + return boolean_rec(it.boolean); + if (!it.faces.empty()) { + std::vector b; + put_i32(b, (int) it.faces.size()); + for (const auto &f : it.faces) + put_i32(b, face(f)); + return add(tag::CONNECTED_FACE_SET, std::move(b)); + } + return -1; + } + int solid_root(const NgeomRoot &root) { + if (root.extrusion) + return extrusion_rec(root.extrusion); + if (root.revolve) + return revolve_rec(root.revolve); + if (root.boolean) + return boolean_rec(root.boolean); + if (root.sphere) + return sphere_rec(root.sphere); + return -1; // sweep (tag 54) not emitted here — falls back to faces + } static void rle_knots(const std::vector &U, std::vector &knots, std::vector &mults) { for (size_t i = 0; i < U.size();) { size_t j = i + 1; From 09b08edb3a6b866fb39a1abbd3c31e956ce1ca1d Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 13:51:03 +0200 Subject: [PATCH 14/65] feat: IfcResolver product-by-representation + IfcRoundedRectangleProfileDef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two IFC-reader parity gaps vs the adapy Python reader: - proxy_roots used a hardcoded product-type name allowlist that missed subtypes (IfcSanitaryTerminal, MEP/furniture terminals, ...), so their products yielded 0 roots. Detect a product by its Representation (arg 6 of every IfcProduct) referencing an IfcProductDefinitionShape — schema-free, catches every geometric product the ifcopenshell reader would. The name allowlist stays as a no-parse fast path for the common structural types. - Added IfcRoundedRectangleProfileDef (rectangle + 4 quarter-circle corner arcs), matching the Python reader. Verified via IfcNgeomStream vs the ifcopenshell oracle: bath-csg-solid.ifc (a rounded-rect IfcCsgSolid on an IfcSanitaryTerminal) now streams 1 root, bbox [0,0,0]..[2,0.8,0.8] exact; beam-extruded-solid still exact. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 35fe379..66da46d 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "../cadit/step/step_part21.h" @@ -49,10 +50,24 @@ class IfcResolver { // Root products: scan for IfcBuildingElementProxy (or any product carrying an AdvancedBrep). Each // yields one NgeomRoot (geometry + per-instance world transforms from IfcMappedItem). std::vector proxy_roots() { - std::vector roots; + // A geometric product is any IfcProduct whose Representation (arg 6 in every IfcProduct + // subtype) refs an IfcProductDefinitionShape. Resolving by representation — instead of the + // old cheap name allowlist, which missed subtypes like IfcSanitaryTerminal / MEP / furniture + // terminals — catches every product the ifcopenshell reader would, with no schema. + std::unordered_set pds; // IfcProductDefinitionShape ids std::string scratch; + for (long id : idx_.ids) + if (iequals(type_of(id, scratch), "IFCPRODUCTDEFINITIONSHAPE")) + pds.insert(id); + std::vector roots; for (long id : idx_.ids) { - if (is_product_type(type_of(id, scratch))) + std::string_view t = type_of(id, scratch); + if (is_product_type(t)) { // fast path for the common structural types (no full parse) + roots.push_back(id); + continue; + } + const Instance *in = inst(id); + if (in && in->args.size() > 6 && in->args[6].is_ref() && pds.count(in->args[6].i)) roots.push_back(id); } return roots; @@ -519,6 +534,29 @@ class IfcResolver { return nullptr; poly = {{-hx, -hy, 0}, {hx, -hy, 0}, {hx, hy, 0}, {-hx, hy, 0}}; apply_placement2d(ref_arg(*in, 2), poly); + } else if (iequals(in->type, "IFCROUNDEDRECTANGLEPROFILEDEF")) { + // (ProfileType, Name, Position, XDim, YDim, RoundingRadius) — a rectangle whose four + // corners are quarter-circle arcs of RoundingRadius (matches the adapy Python reader). + double hx = ad(in, 3) / 2, hy = ad(in, 4) / 2, r = ad(in, 5); + if (hx <= 0 || hy <= 0) + return nullptr; + r = std::min(r, std::min(hx, hy)); + if (r <= 1e-12) { + poly = {{-hx, -hy, 0}, {hx, -hy, 0}, {hx, hy, 0}, {-hx, hy, 0}}; + } else { + auto corner = [&](double cx, double cy, double a0) { // quarter arc, centre (cx,cy), CCW + int n = std::max(2, (int) std::ceil(0.25 * 64)); + for (int i = 0; i <= n; ++i) { + double t = a0 + (PI / 2) * i / n; + poly.push_back({cx + r * std::cos(t), cy + r * std::sin(t), 0}); + } + }; + corner(hx - r, -(hy - r), -PI / 2); // bottom-right + corner(hx - r, hy - r, 0); // top-right + corner(-(hx - r), hy - r, PI / 2); // top-left + corner(-(hx - r), -(hy - r), PI); // bottom-left + } + apply_placement2d(ref_arg(*in, 2), poly); } else if (iequals(in->type, "IFCARBITRARYCLOSEDPROFILEDEF")) { poly = curve_points2d(ref_arg(*in, 2)); // (ProfileType, ProfileName, OuterCurve) } else if (iequals(in->type, "IFCISHAPEPROFILEDEF")) { From 524240b042e0fbc42d3015c64ef3c7fcb6b7698b Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 13:57:51 +0200 Subject: [PATCH 15/65] feat: NGEOM encoder emits swept solids (tag 54); completes solid encoder 50-54 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sweep_rec (SweepN -> FIXED_REF_SWEPT_SOLID: profile face + placement + N per-station origin/dir_x/dir_y frames, layout matching ngeom_decode.h + the Python codec) and wire it into solid_root / solid_item_rec, plus root.sweep into the IfcNgeomStream has_solid gate. The NGEOM encoder now emits every analytic solid tag (50-54), matching the adapy serializer — the prerequisite for streaming the swept-solid family once the IFC reader builds SweepN. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 2 +- src/geom/neutral/ngeom_encode.h | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index c3f73dd..6d4596c 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -643,7 +643,7 @@ class IfcNgeomStream { // in addition to face sets, so a procedural root carries real geometry. Only a root with // NO encodable geometry (empty faces + not one of those solids — e.g. an alignment sweep // or an unsupported product) still yields nothing; skip it for the kernel fallback. - bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere; + bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep; if (root.faces.empty() && !has_solid) { ++skipped_; continue; diff --git a/src/geom/neutral/ngeom_encode.h b/src/geom/neutral/ngeom_encode.h index b9d92e9..97d4fa1 100644 --- a/src/geom/neutral/ngeom_encode.h +++ b/src/geom/neutral/ngeom_encode.h @@ -124,6 +124,19 @@ class Encoder { put_f64(b, sp->radius); return add(tag::SPHERE_SOLID, std::move(b)); } + int sweep_rec(const std::shared_ptr &sw) { + std::vector b; + put_i32(b, face(sw->profile)); + put_i32(b, placement3(sw->frame)); + put_i32(b, (int) sw->origin.size()); + for (const Vec3 &v : sw->origin) + put_v3(b, v); + for (const Vec3 &v : sw->dir_x) + put_v3(b, v); + for (const Vec3 &v : sw->dir_y) + put_v3(b, v); + return add(tag::FIXED_REF_SWEPT_SOLID, std::move(b)); + } int boolean_rec(const std::shared_ptr &bn) { std::vector b; put_i32(b, bn->op); @@ -140,6 +153,8 @@ class Encoder { return revolve_rec(it.revolve); if (it.boolean) return boolean_rec(it.boolean); + if (it.sweep) + return sweep_rec(it.sweep); if (!it.faces.empty()) { std::vector b; put_i32(b, (int) it.faces.size()); @@ -158,7 +173,9 @@ class Encoder { return boolean_rec(root.boolean); if (root.sphere) return sphere_rec(root.sphere); - return -1; // sweep (tag 54) not emitted here — falls back to faces + if (root.sweep) + return sweep_rec(root.sweep); + return -1; } static void rle_knots(const std::vector &U, std::vector &knots, std::vector &mults) { for (size_t i = 0; i < U.size();) { From e138e6087e07be3c69f8015813fa6f1d65c9831d Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 14:05:23 +0200 Subject: [PATCH 16/65] feat: IfcResolver reads face sets + surface models + plain IfcFace (100%-coverage push) Toward pure-C++ 100% geometry coverage (no ifcopenshell hybrid). resolve_solid_item now handles the tessellated / face-based families the Python reader covers: - IfcPolygonalFaceSet / IfcTriangulatedFaceSet: each face/triangle -> a poly-loop FaceSurfaceN from the IfcCartesianPointList3D (new point_list_3d + push_pt helpers). - IfcShellBasedSurfaceModel / IfcFaceBasedSurfaceModel / bare shells: add_shell_faces reads each shell's IfcFace list. - face() now also builds a plain IfcFace (polygonal, no analytic surface -> placeholder plane; the tessellator fits the plane from the loop) and IfcFaceSurface, and a bare face/shell is dispatched as a representation item. Corpus coverage 17/26 files fully streamed (was ~11): polygonal/triangulated tessellation, surface-model, bsplinesurfacewithknots now stream + tessellate == oracle. adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 93 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 66da46d..dd44a92 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -490,14 +490,27 @@ class IfcResolver { return nullptr; } std::shared_ptr face(long id) { - const Instance *in = inst(id); // IfcAdvancedFace(Bounds, FaceSurface, SameSense) - if (!in || !iequals(in->type, "IFCADVANCEDFACE")) + const Instance *in = inst(id); + if (!in) return nullptr; - auto fc = std::make_shared(); - fc->surface = surface(ref_arg(*in, 1)); - if (!fc->surface) + // IfcAdvancedFace / IfcFaceSurface (Bounds, FaceSurface, SameSense) carry an analytic surface; + // a plain IfcFace (Bounds) is a bare polygon — placeholder plane, the tessellator fits the + // real plane from the poly loop (face-based surface models, shells). + bool analytic = iequals(in->type, "IFCADVANCEDFACE") || iequals(in->type, "IFCFACESURFACE"); + bool plain = iequals(in->type, "IFCFACE"); + if (!analytic && !plain) return nullptr; - fc->same_sense = !(in->args.size() > 2 && in->args[2].kind == adacpp::step::Kind::Enum && in->args[2].s == "F"); + auto fc = std::make_shared(); + if (analytic) { + fc->surface = surface(ref_arg(*in, 1)); + if (!fc->surface) + return nullptr; + fc->same_sense = + !(in->args.size() > 2 && in->args[2].kind == adacpp::step::Kind::Enum && in->args[2].s == "F"); + } else { + fc->surface = std::make_shared(Frame{}); + fc->same_sense = true; + } if (in->args.empty() || !in->args[0].is_list()) return nullptr; for (const Value &bref : in->args[0].items) { @@ -897,9 +910,77 @@ class IfcResolver { } else if (iequals(t, "IFCHALFSPACESOLID")) { if (auto ex = mk_halfspace(in, refmin, refmax)) out.extrusion = ex; + } else if (iequals(t, "IFCPOLYGONALFACESET")) { + // (Coordinates=IfcCartesianPointList3D, Closed, Faces=IfcIndexedPolygonalFace[], PnIndex) + std::vector pts = point_list_3d(ref_arg(*in, 0)); + if (in->args.size() > 2 && in->args[2].is_list()) + for (const Value &fref : in->args[2].items) + if (fref.is_ref()) { + const Instance *pf = inst(fref.i); // IfcIndexedPolygonalFace(CoordIndex[, Inner]) + if (!pf || pf->args.empty() || !pf->args[0].is_list()) + continue; + std::vector poly; + for (const Value &iv : pf->args[0].items) + push_pt(poly, pts, iv); + if (auto f = make_profile(poly)) + out.faces.push_back(f); + } + } else if (iequals(t, "IFCTRIANGULATEDFACESET")) { + // (Coordinates, Normals, Closed, CoordIndex=list of (i,j,k) triples, PnIndex) + std::vector pts = point_list_3d(ref_arg(*in, 0)); + if (in->args.size() > 3 && in->args[3].is_list()) + for (const Value &tri : in->args[3].items) + if (tri.is_list() && tri.items.size() >= 3) { + std::vector poly; + for (const Value &iv : tri.items) + push_pt(poly, pts, iv); + if (poly.size() >= 3) + if (auto f = make_profile(poly)) + out.faces.push_back(f); + } + } else if (iequals(t, "IFCSHELLBASEDSURFACEMODEL")) { // (SbsmBoundary = shells) + if (!in->args.empty() && in->args[0].is_list()) + for (const Value &sh : in->args[0].items) + if (sh.is_ref()) + add_shell_faces(sh.i, out); + } else if (iequals(t, "IFCFACEBASEDSURFACEMODEL")) { // (FbsmFaces = IfcConnectedFaceSet[]) + if (!in->args.empty() && in->args[0].is_list()) + for (const Value &cfs : in->args[0].items) + if (cfs.is_ref()) + add_shell_faces(cfs.i, out); + } else if (iequals(t, "IFCCONNECTEDFACESET") || iequals(t, "IFCOPENSHELL") || + iequals(t, "IFCCLOSEDSHELL")) { + add_shell_faces(id, out); + } else if (iequals(t, "IFCADVANCEDFACE") || iequals(t, "IFCFACESURFACE") || iequals(t, "IFCFACE")) { + if (auto f = face(id)) // a bare face as the representation item + out.faces.push_back(f); } return out; } + // IfcCartesianPointList3D(CoordList) -> 1-based point array (index 0 is a placeholder). + std::vector point_list_3d(long id) { + std::vector pts{{0, 0, 0}}; + const Instance *pl = inst(id); + if (pl && !pl->args.empty() && pl->args[0].is_list()) + for (const Value &row : pl->args[0].items) + if (row.is_list() && row.items.size() >= 3) + pts.push_back({row.items[0].as_double(), row.items[1].as_double(), row.items[2].as_double()}); + return pts; + } + static void push_pt(std::vector &poly, const std::vector &pts, const Value &iv) { + long ix = (iv.kind == adacpp::step::Kind::Int) ? iv.i : (long) iv.as_double(); // 1-based CoordIndex + if (ix >= 1 && (size_t) ix < pts.size()) + poly.push_back(pts[ix]); + } + // A shell (IfcClosedShell/IfcOpenShell/IfcConnectedFaceSet): CfsFaces (arg 0) is a list of IfcFace. + void add_shell_faces(long shell_id, SolidItemN &out) { + const Instance *sh = inst(shell_id); + if (sh && !sh->args.empty() && sh->args[0].is_list()) + for (const Value &fref : sh->args[0].items) + if (fref.is_ref()) + if (auto f = face(fref.i)) + out.faces.push_back(f); + } // IfcBooleanResult(Operator, FirstOperand, SecondOperand) -> ng::BooleanN (null if an operand can't // be resolved). op: 0 difference / 1 union / 2 intersection. The 1st operand bounds a half-space 2nd. std::shared_ptr mk_boolean(const Instance *in) { From f6f58d9b5f305a16be69a8133c3bf1fbee7a1570 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 14:21:42 +0200 Subject: [PATCH 17/65] feat: IfcResolver reads IfcSweptDiskSolid (rebar/pipes) -> SweepN Adds the swept-disk solid to the pure-C++ IFC path (100%-coverage push): a directrix sampler (directrix_points: IfcPolyline / IfcIndexedPolyCurve 3D + arc segments via new arc_poly_3d / IfcCompositeCurve), rotation-minimising parallel-transport frames (mirrors adapy _sweep_frames; reseeds a frame that drifts parallel to the tangent), and a circle/annulus disk profile -> SweepN, wired through solid_ok/claim_solid/resolve_item. Also fixes the encoder root-loop gate: it checked extrusion|revolve|boolean|sphere but not sweep, so a sweep-only root fell through to an EMPTY connected-face-set (streamed but 0 tris). Adding root.sweep there is what made swept solids actually render. Verified vs the ifcopenshell oracle: reinforcing-stirrup 13948 tris (was 0), bbox exact; reinforcing-assembly 34 rebar 474k tris. Corpus 18/26 fully covered. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 142 +++++++++++++++++++++++++++++++- src/geom/neutral/ngeom_encode.h | 2 +- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index dd44a92..a129fe7 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -801,7 +801,7 @@ class IfcResolver { mixed_ = true; return false; } - if ((root.extrusion || root.revolve || root.boolean) && solid_src_ != id) { + if ((root.extrusion || root.revolve || root.boolean || root.sweep) && solid_src_ != id) { mixed_ = true; return false; } @@ -809,7 +809,7 @@ class IfcResolver { return true; } static bool solid_ok(const SolidItemN &it) { - return it.extrusion || it.revolve || it.boolean || !it.faces.empty(); + return it.extrusion || it.revolve || it.boolean || it.sweep || !it.faces.empty(); } // Resolve an IfcSolidModel / IfcCsgPrimitive3D / IfcBooleanResult / IfcHalfSpaceSolid into a // SolidItemN (the boolean-operand form). `ref*` = a sibling-operand bbox to bound a half-space. @@ -954,9 +954,146 @@ class IfcResolver { } else if (iequals(t, "IFCADVANCEDFACE") || iequals(t, "IFCFACESURFACE") || iequals(t, "IFCFACE")) { if (auto f = face(id)) // a bare face as the representation item out.faces.push_back(f); + } else if (iequals(t, "IFCSWEPTDISKSOLID")) { + // (Directrix, Radius, InnerRadius, StartParam, EndParam) — a disk/annulus swept along the + // 3D directrix (rebar, pipes). Trim params ignored (full directrix). + std::vector dp = directrix_points(ref_arg(*in, 0)); + double radius = ad(in, 1); + double inner = (in->args.size() > 2 && (in->args[2].kind == adacpp::step::Kind::Real || + in->args[2].kind == adacpp::step::Kind::Int)) + ? in->args[2].as_double() + : 0.0; + out.sweep = mk_swept_disk(dp, radius, inner); } return out; } + // A 3-point 3D circular arc (fit the plane through the points, circumcircle, sweep a->b via m). + static std::vector arc_poly_3d(const Vec3 &a, const Vec3 &m, const Vec3 &b) { + Vec3 ab = b - a, am = m - a, nrm = ab.cross(am); + if (nrm.norm() < 1e-12) + return {a, m, b}; + Vec3 n = nrm.normalized(); + double abab = ab.dot(ab), amam = am.dot(am), abam = ab.dot(am); + double d = 2.0 * (abab * amam - abam * abam); + if (std::abs(d) < 1e-18) + return {a, m, b}; + Vec3 c = a + ab * ((amam * (abab - abam)) / d) + am * ((abab * (amam - abam)) / d); + double r = (a - c).norm(); + Vec3 u = (a - c).normalized(), v = n.cross(u); + auto ang = [&](const Vec3 &p) { Vec3 rp = p - c; return std::atan2(rp.dot(v), rp.dot(u)); }; + double ta = ang(a), tm = ang(m), tb = ang(b); + auto wrap = [](double x) { + while (x < 0) + x += 2 * PI; + while (x >= 2 * PI) + x -= 2 * PI; + return x; + }; + double dab = wrap(tb - ta), dam = wrap(tm - ta); + double sweep = (dam <= dab) ? dab : dab - 2 * PI; + int ns = std::max(2, (int) std::ceil(std::abs(sweep) / (2 * PI) * 64)); + std::vector out; + out.reserve(ns + 1); + for (int i = 0; i <= ns; ++i) { + double th = ta + sweep * i / ns; + out.push_back(c + u * (r * std::cos(th)) + v * (r * std::sin(th))); + } + return out; + } + // A 3D directrix curve -> ordered polyline. Covers IfcPolyline, IfcIndexedPolyCurve (3D, arc-aware), + // and IfcCompositeCurve (chained parent curves). Other bases yield empty (-> the sweep is skipped). + std::vector directrix_points(long cid) { + const Instance *in = inst(cid); + if (!in) + return {}; + std::string_view t = in->type; + auto push = [](std::vector &pts, const Vec3 &p) { + if (pts.empty() || (pts.back() - p).norm() > 1e-9) + pts.push_back(p); + }; + if (iequals(t, "IFCPOLYLINE")) + return point_list(in->args.empty() ? Value{} : in->args[0]); + if (iequals(t, "IFCINDEXEDPOLYCURVE")) { + std::vector coords = point_list_3d(ref_arg(*in, 0)), pts; + auto at = [&](long i) { return (i >= 1 && (size_t) i < coords.size()) ? coords[i] : Vec3{0, 0, 0}; }; + const Value *segs = (in->args.size() > 1 && in->args[1].is_list()) ? &in->args[1] : nullptr; + if (segs && !segs->items.empty()) { + for (size_t k = 0; k + 1 < segs->items.size(); k += 2) { + const Value &kw = segs->items[k], &al = segs->items[k + 1]; + if (kw.kind != adacpp::step::Kind::Keyword || !al.is_list() || al.items.empty() || + !al.items[0].is_list()) + continue; + const auto &ix = al.items[0].items; + if (iequals(kw.s, "IFCARCINDEX") && ix.size() == 3) { + for (const Vec3 &q : arc_poly_3d(at(ix[0].i), at(ix[1].i), at(ix[2].i))) + push(pts, q); + } else + for (const Value &iv : ix) + push(pts, at(iv.kind == adacpp::step::Kind::Int ? iv.i : (long) iv.as_double())); + } + } else + for (size_t i = 1; i < coords.size(); ++i) + push(pts, coords[i]); + return pts; + } + if (iequals(t, "IFCCOMPOSITECURVE") && !in->args.empty() && in->args[0].is_list()) { + std::vector pts; + for (const Value &sref : in->args[0].items) + if (sref.is_ref()) { + const Instance *seg = inst(sref.i); // IfcCompositeCurveSegment(Transition,Same,ParentCurve) + if (seg && seg->args.size() > 2 && seg->args[2].is_ref()) + for (const Vec3 &p : directrix_points(seg->args[2].i)) + push(pts, p); + } + return pts; + } + return {}; + } + // Disk/annulus swept along a directrix -> SweepN with rotation-minimising (parallel-transport) + // per-station frames, so the circular profile doesn't twist. Mirrors adapy's _sweep_frames. + std::shared_ptr mk_swept_disk(const std::vector &pts, double radius, double inner) { + if (pts.size() < 2 || radius <= 0) + return nullptr; + int n = (int) pts.size(); + std::vector tan(n); + for (int i = 0; i < n; ++i) { + Vec3 tv = (i == 0) ? pts[1] - pts[0] : (i == n - 1) ? pts[n - 1] - pts[n - 2] : pts[i + 1] - pts[i - 1]; + tan[i] = tv.norm() > 1e-12 ? tv.normalized() : Vec3{0, 0, 1}; + } + auto sw = std::make_shared(); + sw->frame = Frame{}; // origin/dir_x/dir_y are already world + sw->origin.resize(n); + sw->dir_x.resize(n); + sw->dir_y.resize(n); + Vec3 up = std::abs(tan[0].z) < 0.9 ? Vec3{0, 0, 1} : Vec3{1, 0, 0}; + Vec3 dx = (up - tan[0] * tan[0].dot(up)).normalized(); + for (int i = 0; i < n; ++i) { + if (i > 0) { // parallel-transport dx across the tangent turn (Rodrigues) + Vec3 axis = tan[i - 1].cross(tan[i]); + double s = axis.norm(), cth = tan[i - 1].dot(tan[i]); + if (s > 1e-9) { + axis = axis * (1.0 / s); + double ang = std::atan2(s, cth); + dx = dx * std::cos(ang) + axis.cross(dx) * std::sin(ang) + + axis * (axis.dot(dx) * (1 - std::cos(ang))); + } + } + Vec3 ortho = dx - tan[i] * tan[i].dot(dx); + if (ortho.norm() < 1e-9) { // dx drifted parallel to the tangent — reseed perpendicular + Vec3 up = std::abs(tan[i].z) < 0.9 ? Vec3{0, 0, 1} : Vec3{1, 0, 0}; + ortho = up - tan[i] * tan[i].dot(up); + } + dx = ortho.normalized(); + sw->origin[i] = pts[i]; + sw->dir_x[i] = dx; + sw->dir_y[i] = tan[i].cross(dx); + } + std::vector> holes; + if (inner > 1e-9) + holes.push_back(circle_poly(inner)); + sw->profile = make_profile(circle_poly(radius), holes); + return sw->profile ? sw : nullptr; + } // IfcCartesianPointList3D(CoordList) -> 1-based point array (index 0 is a placeholder). std::vector point_list_3d(long id) { std::vector pts{{0, 0, 0}}; @@ -1119,6 +1256,7 @@ class IfcResolver { root.extrusion = it.extrusion; root.revolve = it.revolve; root.boolean = it.boolean; + root.sweep = it.sweep; } } // IfcCartesianTransformationOperator3D(Axis1,Axis2,LocalOrigin,Scale,Axis3[,Scale2,Scale3]) diff --git a/src/geom/neutral/ngeom_encode.h b/src/geom/neutral/ngeom_encode.h index 97d4fa1..6f5787d 100644 --- a/src/geom/neutral/ngeom_encode.h +++ b/src/geom/neutral/ngeom_encode.h @@ -32,7 +32,7 @@ class Encoder { // procedural root carries real geometry — previously only CONNECTED_FACE_SET was emitted, // so such a root became an empty buffer and the IfcNgeomStream consumer dropped it. int gi = -1; - if (root.extrusion || root.revolve || root.boolean || root.sphere) + if (root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep) gi = solid_root(root); if (gi < 0) { std::vector body; From 1ac3da6a33c753792795e0f4634e72db1b07a038 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 14:25:52 +0200 Subject: [PATCH 18/65] feat: IfcResolver reads FixedReferenceSweptAreaSolid + SectionedSolidHorizontal Complete the swept-solid readers with a fixed-reference frame builder (mk_fixed_ref_swept: the profile's local-x tracks a fixed reference direction projected perpendicular to the tangent). IfcFixedReferenceSweptAreaSolid uses its FixedReference; IfcSectionedSolidHorizontal (uniform cross-sections) uses a vertical reference; varying sections stay skipped (not a single SweepN). Correct for simple directrixes; the corpus's fixed-ref/sectioned all ride IfcGradientCurve alignment directrixes, so they light up once alignment-curve sampling lands. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index a129fe7..c87a335 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -964,9 +964,63 @@ class IfcResolver { ? in->args[2].as_double() : 0.0; out.sweep = mk_swept_disk(dp, radius, inner); + } else if (iequals(t, "IFCFIXEDREFERENCESWEPTAREASOLID")) { + // (SweptArea, Position, Directrix, StartParam, EndParam, FixedReference) + auto prof = profile_face(ref_arg(*in, 0)); + Frame pos = axis2(ref_arg(*in, 1)); + std::vector dp = directrix_points(ref_arg(*in, 2)); + Vec3 fref = (in->args.size() > 5 && in->args[5].is_ref()) ? dir(in->args[5].i) : Vec3{0, 0, 1}; + out.sweep = mk_fixed_ref_swept(prof, dp, pos, fref); + } else if (iequals(t, "IFCSECTIONEDSOLIDHORIZONTAL")) { + // (Directrix, CrossSections, CrossSectionPositions, FixedAxisVertical). Uniform sections + // (all CrossSections identical) sweep like a fixed-vertical-reference solid; varying + // sections aren't a single SweepN -> skipped. + std::vector dp = directrix_points(ref_arg(*in, 0)); + long sec0 = -1; + bool uniform = true; + if (in->args.size() > 1 && in->args[1].is_list()) + for (const Value &s : in->args[1].items) + if (s.is_ref()) { + if (sec0 < 0) + sec0 = s.i; + else if (s.i != sec0) + uniform = false; + } + if (uniform && sec0 >= 0) + out.sweep = mk_fixed_ref_swept(profile_face(sec0), dp, Frame{}, Vec3{0, 0, 1}); } return out; } + // Fixed-reference sweep: the profile's local-x tracks a FIXED reference direction (projected + // perpendicular to the tangent), not a rotation-minimising frame. Used by + // IfcFixedReferenceSweptAreaSolid + (with a vertical reference) IfcSectionedSolidHorizontal. + std::shared_ptr mk_fixed_ref_swept(std::shared_ptr profile, const std::vector &pts, + const Frame &pos, Vec3 fref) { + if (!profile || pts.size() < 2) + return nullptr; + int n = (int) pts.size(); + Vec3 fr = fref.norm() > 1e-9 ? fref.normalized() : Vec3{0, 0, 1}; + auto sw = std::make_shared(); + sw->frame = pos; + sw->profile = profile; + sw->origin.resize(n); + sw->dir_x.resize(n); + sw->dir_y.resize(n); + for (int i = 0; i < n; ++i) { + Vec3 tv = (i == 0) ? pts[1] - pts[0] : (i == n - 1) ? pts[n - 1] - pts[n - 2] : pts[i + 1] - pts[i - 1]; + Vec3 t = tv.norm() > 1e-12 ? tv.normalized() : Vec3{1, 0, 0}; + Vec3 dx = fr - t * t.dot(fr); + if (dx.norm() < 1e-9) { // reference parallel to the tangent — any perpendicular will do + Vec3 up = std::abs(t.z) < 0.9 ? Vec3{0, 0, 1} : Vec3{1, 0, 0}; + dx = up - t * t.dot(up); + } + dx = dx.normalized(); + sw->origin[i] = pts[i]; + sw->dir_x[i] = dx; + sw->dir_y[i] = t.cross(dx); + } + return sw; + } // A 3-point 3D circular arc (fit the plane through the points, circumcircle, sweep a->b via m). static std::vector arc_poly_3d(const Vec3 &a, const Vec3 &m, const Vec3 &b) { Vec3 ab = b - a, am = m - a, nrm = ab.cross(am); From 86ba34a3c3ad9c7ef69a349d5e6ce1af1700f2bb Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 15:49:03 +0200 Subject: [PATCH 19/65] feat: IfcResolver samples IFC alignment directrixes (clothoid/cant) + IfcDerivedProfileDef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the adapy alignment evaluator (ngeom._alignment_sweep) to C++ so IfcFixedReferenceSweptAreaSolid / IfcSectionedSolidHorizontal over alignment directrixes tessellate natively: - alignment_fresnel (power-series Fresnel S/C for IfcClothoid), alignment_parent_eval (IfcLine / IfcCircle / IfcClothoid / IfcCosineSpiral -> point+tangent), alignment_seg_eval (place a parent by the IfcCurveSegment's IfcAxis2Placement2D, advancing arc length by the sign of SegmentLength), alignment_sample over a composite's IfcCurveSegments, and alignment_gradient_points (IfcGradientCurve: horizontal base + z(s) from the vertical gradient). Wired into directrix_points for IfcGradientCurve / IfcSegmentedReferenceCurve / alignment IfcCompositeCurve. - IfcDerivedProfileDef in profile_face (parent outline + 2D operator transform) — the swept area of these alignment solids. The alignment SWEPT SOLIDS now stream + tessellate (fixed-ref / sectioned each render a solid). The alignment CURVE-only products (IfcAlignmentSegment/Referent axis curves) still skip — they need a GL_LINES curve representation, a separate mechanism. adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 255 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index c87a335..97ef75c 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -570,6 +570,37 @@ class IfcResolver { corner(-(hx - r), -(hy - r), PI); // bottom-left } apply_placement2d(ref_arg(*in, 2), poly); + } else if (iequals(in->type, "IFCDERIVEDPROFILEDEF")) { + // (ProfileType, Name, ParentProfile, Operator=IfcCartesianTransformationOperator2D, Label) + auto base = profile_face(ref_arg(*in, 2)); + if (!base || base->bounds.empty()) + return nullptr; + double c = 1, s = 0, ox = 0, oy = 0, sc = 1; + const Instance *op = inst(ref_arg(*in, 3)); + if (op) { + if (!op->args.empty() && op->args[0].is_ref()) { + Vec3 a = dir(op->args[0].i); + double nn = std::hypot(a.x, a.y); + if (nn > 1e-12) { + c = a.x / nn; + s = a.y / nn; + } + } + if (op->args.size() > 2 && op->args[2].is_ref()) { + Vec3 o = point(op->args[2].i); + ox = o.x; + oy = o.y; + } + if (op->args.size() > 3 && numeric(op->args[3])) + sc = op->args[3].as_double(); + } + for (auto &b : base->bounds) + if (b.loop && b.loop->is_poly) + for (Vec3 &p : b.loop->polygon) { + double x = p.x * sc, y = p.y * sc; + p = {ox + c * x - s * y, oy + s * x + c * y, 0}; + } + return base; } else if (iequals(in->type, "IFCARBITRARYCLOSEDPROFILEDEF")) { poly = curve_points2d(ref_arg(*in, 2)); // (ProfileType, ProfileName, OuterCurve) } else if (iequals(in->type, "IFCISHAPEPROFILEDEF")) { @@ -1021,6 +1052,216 @@ class IfcResolver { } return sw; } + + // ---- IFC alignment curve sampling (port of adapy ngeom._alignment_sweep) ---------------------- + // Normalised Fresnel S(t)=∫sin(πu²/2), C(t)=∫cos(πu²/2) via power series (clothoid transitions). + static void alignment_fresnel(double t, double &S, double &C) { + double hp = PI / 2.0, t4 = t * t * t * t; + C = 0.0; + double term = t; + for (int n = 0; n < 200; ++n) { + double c = term / (4 * n + 1); + C += c; + if (std::abs(c) < 1e-18 && n > 2) + break; + term *= -(hp * hp) * t4 / ((2 * n + 1) * (2 * n + 2)); + } + S = 0.0; + term = hp * t * t * t; + for (int n = 0; n < 200; ++n) { + double s = term / (4 * n + 3); + S += s; + if (std::abs(s) < 1e-18 && n > 2) + break; + term *= -(hp * hp) * t4 / ((2 * n + 2) * (2 * n + 3)); + } + } + static bool numeric(const Value &v) { + return v.kind == adacpp::step::Kind::Real || v.kind == adacpp::step::Kind::Int; + } + // Parent curve at arc length p (its own 2D frame) -> (point2d, unit tangent2d), as z=0 Vec3. + bool alignment_parent_eval(long cid, double p, double seg_len, Vec3 &P, Vec3 &T) { + const Instance *in = inst(cid); + if (!in) + return false; + std::string_view t = in->type; + if (iequals(t, "IFCLINE")) { + P = {p, 0, 0}; + T = {1, 0, 0}; + return true; + } + if (iequals(t, "IFCCIRCLE")) { + double r = ad(in, 1); + if (std::abs(r) < 1e-12) + return false; + double th = p / r; + P = {r * std::cos(th), r * std::sin(th), 0}; + T = {-std::sin(th), std::cos(th), 0}; + return true; + } + if (iequals(t, "IFCCLOTHOID")) { + double A = ad(in, 1), scale = std::abs(A) * std::sqrt(PI); + if (scale < 1e-12) { + P = {p, 0, 0}; + T = {1, 0, 0}; + return true; + } + double tt = p / scale, S, C; + alignment_fresnel(tt, S, C); + double sgn = A < 0 ? -1.0 : 1.0, ph = PI * tt * tt / 2.0; + P = {scale * C, sgn * scale * S, 0}; + T = Vec3{std::cos(ph), sgn * std::sin(ph), 0}.normalized(); + return true; + } + if (iequals(t, "IFCCOSINESPIRAL")) { + double A1 = ad(in, 1); + double A0 = (in->args.size() > 2 && numeric(in->args[2])) ? in->args[2].as_double() : 0.0; + double L = std::abs(seg_len); + auto theta = [&](double s) { + double th = (A0 != 0.0) ? s / A0 : 0.0; + if (L > 0.0 && A1 != 0.0) + th += (L / (PI * A1)) * std::sin(PI * s / L); + return th; + }; + int n = std::max(32, (int) (std::abs(p) / 0.1) + 1); + double px = 0, py = 0, dx = p / n; + for (int i = 1; i <= n; ++i) { + double th = theta(p * i / n), thm = theta(p * (i - 1) / n); + px += (std::cos(th) + std::cos(thm)) * 0.5 * dx; + py += (std::sin(th) + std::sin(thm)) * 0.5 * dx; + } + double thp = theta(p); + P = {px, py, 0}; + T = {std::cos(thp), std::sin(thp), 0}; + return true; + } + return false; + } + // Global (point2d, tangent2d) at local_len along an IfcCurveSegment (parent placed by its + // IfcAxis2Placement2D, arc length advancing in the sign of SegmentLength). + bool alignment_seg_eval(const Instance *seg, double local_len, Vec3 &gp, Vec3 >) { + long placement = -1, parent = -1; + std::vector meas; + for (const Value &a : seg->args) { + if (a.is_ref()) { + if (placement < 0) + placement = a.i; + else + parent = a.i; + } else if (a.is_list() && !a.items.empty() && numeric(a.items[0])) + meas.push_back(a.items[0].as_double()); + } + if (parent < 0 || meas.size() < 2) + return false; + double seg_start = meas[0], seg_length = meas[1], sgn = seg_length >= 0 ? 1.0 : -1.0; + Vec3 P, T, P0, T0; + if (!alignment_parent_eval(parent, seg_start + sgn * local_len, seg_length, P, T)) + return false; + alignment_parent_eval(parent, seg_start, seg_length, P0, T0); + if (sgn < 0) { + T = T * -1.0; + T0 = T0 * -1.0; + } + // placement IfcAxis2Placement2D(Location, RefDirection) + Vec3 o{0, 0, 0}, rd{1, 0, 0}; + const Instance *pl = inst(placement); + if (pl) { + if (!pl->args.empty() && pl->args[0].is_ref()) + o = point(pl->args[0].i); + if (pl->args.size() > 1 && pl->args[1].is_ref()) + rd = dir(pl->args[1].i); + } + double rn = std::hypot(rd.x, rd.y); + if (rn > 1e-12) { + rd.x /= rn; + rd.y /= rn; + } + double tn = std::hypot(T0.x, T0.y); + Vec3 t0 = tn > 1e-12 ? Vec3{T0.x / tn, T0.y / tn, 0} : Vec3{1, 0, 0}; + // R = 2x2 rotation taking t0 -> rd + double c = t0.x * rd.x + t0.y * rd.y, s = t0.x * rd.y - t0.y * rd.x; + auto rot = [&](const Vec3 &v) { return Vec3{c * v.x - s * v.y, s * v.x + c * v.y, 0}; }; + Vec3 dp = rot(P - P0); + gp = {o.x + dp.x, o.y + dp.y, 0}; + gt = rot(T).normalized(); + return true; + } + // Sample the IfcCurveSegments of a composite/base curve -> (cumulative arc length s, 2D point). + void alignment_sample(long curve_id, int n_per, std::vector &s_out, std::vector &p_out) { + const Instance *in = inst(curve_id); + if (!in || in->args.empty() || !in->args[0].is_list()) + return; + double s_acc = 0.0; + for (const Value &sref : in->args[0].items) { + if (!sref.is_ref()) + continue; + const Instance *seg = inst(sref.i); + if (!seg || !iequals(seg->type, "IFCCURVESEGMENT")) + continue; + double L = 0.0; + std::vector meas; + for (const Value &a : seg->args) + if (a.is_list() && !a.items.empty() && numeric(a.items[0])) + meas.push_back(a.items[0].as_double()); + if (meas.size() >= 2) + L = std::abs(meas[1]); + if (L < 1e-9) + continue; + for (int i = 0; i <= n_per; ++i) { + Vec3 gp, gt; + if (alignment_seg_eval(seg, L * i / n_per, gp, gt)) { + s_out.push_back(s_acc + L * i / n_per); + p_out.push_back(gp); + } + } + s_acc += L; + } + } + // A composite/segmented alignment curve of IfcCurveSegments -> planar 3D directrix (z=0). + std::vector alignment_planar_points(long cid) { + std::vector s; + std::vector p; + alignment_sample(cid, 24, s, p); + return p; + } + // IfcGradientCurve(Segments=vertical gradient, SelfIntersect, BaseCurve=horizontal, EndPoint) -> + // 3D directrix: horizontal (x,y) from BaseCurve + z(s) interpolated from the vertical gradient. + std::vector alignment_gradient_points(long cid) { + const Instance *in = inst(cid); + if (!in) + return {}; + long base = (in->args.size() > 2 && in->args[2].is_ref()) ? in->args[2].i : -1; + std::vector hs; + std::vector hp; + if (base >= 0) + alignment_sample(base, 24, hs, hp); // horizontal (x,y) along s + else + alignment_sample(cid, 24, hs, hp); // SegmentedReferenceCurve: sample its own segments + // vertical gradient z(s): the gradient's own segments give (distance, height) points + std::vector vs; + std::vector vp; + if (iequals(in->type, "IFCGRADIENTCURVE")) + alignment_sample(cid, 24, vs, vp); // segments (arg 0) are the vertical profile + std::vector out; + out.reserve(hp.size()); + for (size_t i = 0; i < hp.size(); ++i) { + double z = 0.0; + if (!vp.empty()) { // interpolate height over the vertical profile's (x=distance, y=height) + double q = hs[i]; + z = vp.front().y; + for (size_t k = 0; k + 1 < vp.size(); ++k) + if ((vp[k].x <= q && q <= vp[k + 1].x) || (vp[k + 1].x <= q && q <= vp[k].x)) { + double dxk = vp[k + 1].x - vp[k].x; + z = std::abs(dxk) < 1e-12 ? vp[k].y + : vp[k].y + (vp[k + 1].y - vp[k].y) * (q - vp[k].x) / dxk; + break; + } else if (q > vp[k + 1].x) + z = vp[k + 1].y; + } + out.push_back({hp[i].x, hp[i].y, z}); + } + return out; + } // A 3-point 3D circular arc (fit the plane through the points, circumcircle, sweep a->b via m). static std::vector arc_poly_3d(const Vec3 &a, const Vec3 &m, const Vec3 &b) { Vec3 ab = b - a, am = m - a, nrm = ab.cross(am); @@ -1090,6 +1331,20 @@ class IfcResolver { push(pts, coords[i]); return pts; } + // Alignment directrixes: an IfcGradientCurve (horizontal composite + vertical gradient) or a + // composite/segmented curve of IfcCurveSegments (line/arc/clothoid/cosine-spiral transitions). + if (iequals(t, "IFCGRADIENTCURVE") || iequals(t, "IFCSEGMENTEDREFERENCECURVE")) + return alignment_gradient_points(cid); + if ((iequals(t, "IFCCOMPOSITECURVE")) && !in->args.empty() && in->args[0].is_list()) { + // alignment composite: its segments are IfcCurveSegment (not IfcCompositeCurveSegment) + for (const Value &sref : in->args[0].items) + if (sref.is_ref()) { + const Instance *s0 = inst(sref.i); + if (s0 && iequals(s0->type, "IFCCURVESEGMENT")) + return alignment_planar_points(cid); + break; + } + } if (iequals(t, "IFCCOMPOSITECURVE") && !in->args.empty() && in->args[0].is_list()) { std::vector pts; for (const Value &sref : in->args[0].items) From 3d6e62dcda8e017d92fa04f4628134bcf9239f50 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 16:08:32 +0200 Subject: [PATCH 20/65] feat: NGEOM curve-only bodies -> GL_LINES (alignment axes, trimmed/segmented curves) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a curve/polyline representation to the NGEOM stream so curve-only IFC products render as lines instead of skipping: - NgeomRoot.polylines + a new CURVE_SET tag (67): encode/decode N polylines of points (C++-only, not in the Python codec — these blobs are C++-produced and C++-consumed). - TessMesh.mesh_type; tessellate_one_root emits index-pair LINES for a polyline root, and tessellate_stream threads mesh_type onto the returned Mesh (MeshType::LINES). - IfcResolver: a curve-body branch in resolve_item builds root.polylines via the directrix sampler for IfcGradientCurve / IfcSegmentedReferenceCurve / IfcCompositeCurve / IfcPolyline / IfcIndexedPolyCurve / IfcTrimmedCurve / IfcCurveSegment (single alignment segment); the IfcNgeomStream gate no longer skips a polyline-only root. Corpus 18/26 -> 21/26 fully covered: segmented-reference-curve + curve-parameters(-degrees/ -radians) now 100%; fixed-ref/sectioned 8/10 (7 alignment axes as LINES + 1 swept solid). adacpp suites green (62). NOTE: the raw adacpp Mesh carries mesh_type=LINES; the adapy backend BatchMesh wrapper still needs to propagate it (MeshStore.type) for the viewer to render these as lines not triangles. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 5 +++-- src/cad/ifc_reader.h | 29 +++++++++++++++++++++++++++ src/geom/neutral/ngeom_decode.h | 17 ++++++++++++++++ src/geom/neutral/ngeom_encode.h | 12 ++++++++++- src/geom/neutral/ngeom_tessellate.cpp | 20 ++++++++++++++++++ src/geom/neutral/ngeom_tessellate.h | 4 ++++ src/geom/neutral/ngeom_topology.h | 1 + 7 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 6d4596c..297457a 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -442,7 +442,7 @@ Mesh tessellate_stream_impl(nb::object buffer, const std::string &pipeline, doub groups.emplace_back((int) i, (int) g.first_index, (int) g.index_count, (int) g.first_vertex, (int) g.vertex_count); } - Mesh mesh(0, std::move(tm.positions), std::move(tm.indices), {}, std::move(tm.normals)); + Mesh mesh(0, std::move(tm.positions), std::move(tm.indices), {}, std::move(tm.normals), tm.mesh_type); mesh.group_reference = std::move(groups); return mesh; } @@ -643,7 +643,8 @@ class IfcNgeomStream { // in addition to face sets, so a procedural root carries real geometry. Only a root with // NO encodable geometry (empty faces + not one of those solids — e.g. an alignment sweep // or an unsupported product) still yields nothing; skip it for the kernel fallback. - bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep; + bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep || + !root.polylines.empty(); if (root.faces.empty() && !has_solid) { ++skipped_; continue; diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 97ef75c..104acb9 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -1335,6 +1335,23 @@ class IfcResolver { // composite/segmented curve of IfcCurveSegments (line/arc/clothoid/cosine-spiral transitions). if (iequals(t, "IFCGRADIENTCURVE") || iequals(t, "IFCSEGMENTEDREFERENCECURVE")) return alignment_gradient_points(cid); + if (iequals(t, "IFCCURVESEGMENT")) { // a single alignment segment (IfcAlignmentSegment axis) + double L = 0.0; + std::vector meas; + for (const Value &a : in->args) + if (a.is_list() && !a.items.empty() && numeric(a.items[0])) + meas.push_back(a.items[0].as_double()); + if (meas.size() >= 2) + L = std::abs(meas[1]); + std::vector pts; + if (L > 1e-9) + for (int i = 0; i <= 24; ++i) { + Vec3 gp, gt; + if (alignment_seg_eval(in, L * i / 24, gp, gt)) + pts.push_back(gp); + } + return pts; + } if ((iequals(t, "IFCCOMPOSITECURVE")) && !in->args.empty() && in->args[0].is_list()) { // alignment composite: its segments are IfcCurveSegment (not IfcCompositeCurveSegment) for (const Value &sref : in->args[0].items) @@ -1551,6 +1568,18 @@ class IfcResolver { root.transforms.push_back(M); return; } + // Curve-only body (alignment axis / reference curve / a bare curve rep item) -> a polyline + // rendered as GL_LINES. Reuse the directrix sampler (handles gradient/segmented/composite/ + // polyline/indexed + alignment clothoid/cant). + if (iequals(in->type, "IFCGRADIENTCURVE") || iequals(in->type, "IFCSEGMENTEDREFERENCECURVE") || + iequals(in->type, "IFCCOMPOSITECURVE") || iequals(in->type, "IFCPOLYLINE") || + iequals(in->type, "IFCINDEXEDPOLYCURVE") || iequals(in->type, "IFCTRIMMEDCURVE") || + iequals(in->type, "IFCCURVESEGMENT")) { + std::vector pts = directrix_points(id); + if (pts.size() >= 2) + root.polylines.push_back(std::move(pts)); + return; + } SolidItemN it = resolve_solid_item(id); if (!solid_ok(it)) return; // unsupported geometry -> product yields nothing here -> OCC fallback diff --git a/src/geom/neutral/ngeom_decode.h b/src/geom/neutral/ngeom_decode.h index d316b48..e6e79e8 100644 --- a/src/geom/neutral/ngeom_decode.h +++ b/src/geom/neutral/ngeom_decode.h @@ -58,6 +58,7 @@ enum : int { FACE_BOUND = 64, FACE_SURFACE = 65, CONNECTED_FACE_SET = 66, + CURVE_SET = 67, // a curve-only body: N polylines (GL_LINES). C++-only (not in the Python codec). }; } @@ -155,6 +156,7 @@ struct Slot { std::shared_ptr sweep; std::shared_ptr boolean; std::shared_ptr sphere; + std::vector> polylines; std::shared_ptr oedge; // EDGE_CURVE intermediate Vec3 e_start, e_end; @@ -439,6 +441,19 @@ inline NgeomDoc decode(const uint8_t *data, size_t n) { slot.cfs = c; break; } + case tag::CURVE_SET: { + int np = r.i32(); + slot.polylines.reserve(np); + for (int i = 0; i < np; ++i) { + int npts = r.i32(); + std::vector line; + line.reserve(npts); + for (int j = 0; j < npts; ++j) + line.push_back(r.vec3()); + slot.polylines.push_back(std::move(line)); + } + break; + } case tag::EXTRUDED_AREA_SOLID: { auto ex = std::make_shared(); ex->profile = S.at(r.i32()).face; // profile FACE_SURFACE @@ -519,6 +534,8 @@ inline NgeomDoc decode(const uint8_t *data, size_t n) { root.boolean = gs.boolean; } else if (gs.sphere) { root.sphere = gs.sphere; + } else if (!gs.polylines.empty()) { + root.polylines = gs.polylines; } else if (gs.face) { root.faces.push_back(gs.face); } else if (gs.cfs) { diff --git a/src/geom/neutral/ngeom_encode.h b/src/geom/neutral/ngeom_encode.h index 6f5787d..eeb2d20 100644 --- a/src/geom/neutral/ngeom_encode.h +++ b/src/geom/neutral/ngeom_encode.h @@ -32,7 +32,17 @@ class Encoder { // procedural root carries real geometry — previously only CONNECTED_FACE_SET was emitted, // so such a root became an empty buffer and the IfcNgeomStream consumer dropped it. int gi = -1; - if (root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep) + if (!root.polylines.empty()) { + std::vector b; + put_i32(b, (int) root.polylines.size()); + for (const auto &line : root.polylines) { + put_i32(b, (int) line.size()); + for (const Vec3 &p : line) + put_v3(b, p); + } + gi = add(tag::CURVE_SET, std::move(b)); + } + if (gi < 0 && (root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep)) gi = solid_root(root); if (gi < 0) { std::vector body; diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 73a0716..9024199 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1850,6 +1850,26 @@ static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, Tes } else if (root.sphere) { Mesh m(out); tessellate_sphere(*root.sphere, tp, m); + } else if (!root.polylines.empty()) { + // Curve-only body -> GL_LINES: emit each polyline's points as a line strip (index pairs). + out.mesh_type = MeshType::LINES; + for (const auto &line : root.polylines) { + if (line.size() < 2) + continue; + uint32_t base = (uint32_t) (out.positions.size() / 3); + for (const Vec3 &p : line) { + out.positions.push_back((float) p.x); + out.positions.push_back((float) p.y); + out.positions.push_back((float) p.z); + out.normals.push_back(0.0f); + out.normals.push_back(0.0f); + out.normals.push_back(1.0f); + } + for (uint32_t i = 0; i + 1 < line.size(); ++i) { + out.indices.push_back(base + i); + out.indices.push_back(base + i + 1); + } + } } else { if (FDBG) { size_t nnull = 0; diff --git a/src/geom/neutral/ngeom_tessellate.h b/src/geom/neutral/ngeom_tessellate.h index 1137ce2..e3995b2 100644 --- a/src/geom/neutral/ngeom_tessellate.h +++ b/src/geom/neutral/ngeom_tessellate.h @@ -11,6 +11,7 @@ #include #include +#include "../MeshType.h" #include "ngeom_topology.h" namespace adacpp::ngeom { @@ -43,6 +44,9 @@ struct TessMesh { uint32_t vertex_count = 0; }; std::vector groups; + // Primitive topology of `indices`: TRIANGLES for solids/faces, LINES for curve-only bodies + // (index pairs). A single stream blob is one root, so it is homogeneous. + MeshType mesh_type = MeshType::TRIANGLES; }; // Tessellate one neutral face, appending into `out`. Returns true if it produced triangles. diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 95ae14e..e67d469 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -377,6 +377,7 @@ struct NgeomRoot { std::shared_ptr sweep; // set if this root is a fixed-ref swept solid std::shared_ptr boolean; // set if this root is a boolean result std::shared_ptr sphere; // set if this root is a sphere primitive + std::vector> polylines; // set if this root is a curve-only body (GL_LINES) // Presentation colour (STYLED_ITEM -> COLOUR_RGB), populated by the native STEP reader; the // NGEOM byte decoder leaves has_color=false (colour travels out-of-band on that path). bool has_color = false; From 666e4cb60fff3085a2b684cdf4de1664dd9758db Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 16:31:33 +0200 Subject: [PATCH 21/65] feat: representation-class filter + 2D trimmed/composite curve profiles (real solids over axis lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled IfcResolver fixes so products render their actual Body solid, not a reference line: 1. Representation-class filter in resolve_product: a product may carry several IfcShapeRepresen- tations ("Body"/"Facetation"/"Tessellation" = the 3D shape; "Axis" = a reference curve; "FootPrint"/"Annotation"/"Profile"/"Plan"/"Box" = 2D). Previously ALL items were resolved into one root, so a beam's Axis polyline collided with its Body extrusion and an alignment merged its 3D gradient Axis with its 2D FootPrint. Now: skip 2D reps always; skip Axis when a body exists. This exposed that several products were only ever drawing their Axis line (false coverage) while the real solid silently failed — see (2). 2. Curved profile outlines: curve_points2d now samples IfcCompositeCurve (of IfcCompositeCurve- Segment, honoring SameSense) and IfcTrimmedCurve over IfcLine (straight span) / IfcCircle (arc). PARAMETER circle trims are angles in the model plane-angle unit, so plane_angle_scale() reads the IfcConversionBasedUnit ConversionFactor -> curve-parameters-in-degrees and -in-radians now sample the *same* semicircle (verified byte-for-byte identical tessellation, bbox 1707x1707x2000). Result: reinforcing-assembly IfcBeam renders its 200x400x5000 Body extrusion (was the axis line); curve-parameters columns render their semicircle solids. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 174 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 104acb9..fe562f9 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -128,6 +128,40 @@ class IfcResolver { return 1.0; } + // Radians per model plane-angle unit. IfcSIUnit RADIAN => 1; an IfcConversionBasedUnit for + // PLANEANGLEUNIT (DEGREE/GRAD) carries the exact ratio in its ConversionFactor + // (IfcMeasureWithUnit.ValueComponent) -> use it so trimmed-circle parameters expressed in + // degrees (curve-parameters-in-degrees.ifc) sample the same arc as the radian model. + double plane_angle_scale() { + if (angle_scale_ > 0) + return angle_scale_; + angle_scale_ = 1.0; // default: radian + std::string scratch; + for (long id : idx_.ids) { + std::string_view t = type_of(id, scratch); + if (!iequals(t, "IFCCONVERSIONBASEDUNIT")) + continue; + const Instance *in = inst(id); // (Dimensions, UnitType, Name, ConversionFactor) + if (!in || in->args.size() < 4 || + !(in->args[1].kind == adacpp::step::Kind::Enum && in->args[1].s == "PLANEANGLEUNIT")) + continue; + const Instance *mwu = inst(ref_arg(*in, 3)); // IfcMeasureWithUnit(ValueComponent, UnitComponent) + if (mwu) // ValueComponent = IfcPlaneAngleMeasure(x): [Keyword, List[Real]] + for (const Value &a : mwu->args) { + if (numeric(a)) { + angle_scale_ = a.as_double(); + break; + } + if (a.is_list() && !a.items.empty() && numeric(a.items[0])) { + angle_scale_ = a.items[0].as_double(); + break; + } + } + break; + } + return angle_scale_; + } + // Build the NgeomRoot for a product id (faces + name + per-instance transforms). Empty faces => the // product had no resolvable advanced-brep (skipped by the caller). NgeomRoot resolve_product(long pid) { @@ -144,10 +178,42 @@ class IfcResolver { const Instance *pds = inst(rep); if (!pds || pds->args.size() < 3) return root; + // IfcShapeRepresentation: (ContextOfItems, RepresentationIdentifier, RepresentationType, Items). + // A product may carry several reps: "Body"/"Facetation"/"Tessellation" are the 3D shape; + // "Axis" is a reference curve (render only when there is NO body — an alignment segment IS + // its axis); "FootPrint"/"Annotation"/"Profile"/"Plan"/"Box" are 2D and never rendered in + // 3D. Pick the right class so a beam's Axis polyline doesn't collide with its Body extrusion, + // and an alignment takes its 3D gradient Axis rather than its 2D FootPrint. + auto rep_id = [](const Instance *sr) -> std::string_view { + if (sr->args.size() > 1 && sr->args[1].kind == adacpp::step::Kind::Str) + return sr->args[1].s; + return {}; + }; + auto is_2d_only = [](std::string_view id) { + return id == "FootPrint" || id == "Annotation" || id == "Profile" || id == "Plan" || + id == "Box"; + }; + auto is_axis = [](std::string_view id) { return id == "Axis"; }; + bool has_body = false; + for (const Value &srref : pds->args[2].items) { + const Instance *sr = inst(srref.i); + if (sr && sr->args.size() >= 4) { + std::string_view id = rep_id(sr); + if (!is_2d_only(id) && !is_axis(id)) { + has_body = true; + break; + } + } + } for (const Value &srref : pds->args[2].items) { const Instance *sr = inst(srref.i); if (!sr || sr->args.size() < 4) continue; + std::string_view id = rep_id(sr); + if (is_2d_only(id)) + continue; // 2D reps are never rendered in 3D + if (has_body && is_axis(id)) + continue; // a body exists -> the Axis is only a reference line, skip it for (const Value &item : sr->args[3].items) { if (!item.is_ref()) continue; @@ -206,8 +272,9 @@ class IfcResolver { std::unordered_map> cache_; std::unordered_map> surf_cache_; std::string pread_scratch_; - long solid_src_ = 0; // entity id of the one solid this product carries (mapped instances share it) - bool mixed_ = false; // product has >1 distinct solid / mixes brep+procedural -> skip (OCC) + long solid_src_ = 0; // entity id of the one solid this product carries (mapped instances share it) + bool mixed_ = false; // product has >1 distinct solid / mixes brep+procedural -> skip (OCC) + double angle_scale_ = 0.0; // radians per model plane-angle unit (0 => not yet computed) const Instance *inst(long id) { if (id <= 0) @@ -800,11 +867,114 @@ class IfcResolver { for (size_t i = 1; i < coords.size(); ++i) push(coords[i]); } + } else if (iequals(in->type, "IFCCOMPOSITECURVE")) { + // (Segments=list of IfcCompositeCurveSegment, SelfIntersect). Each segment is + // (Transition, SameSense, ParentCurve); sample each parent (line/arc/polyline) and + // reverse it when SameSense=.F. so the outline stays continuous. This is how a curved + // profile outline (e.g. a semicircle of a trimmed arc + a closing trimmed line) is built. + if (!in->args.empty() && in->args[0].is_list()) + for (const Value &sref : in->args[0].items) { + if (!sref.is_ref()) + continue; + const Instance *seg = inst(sref.i); + if (!seg || seg->args.size() < 3 || !seg->args[2].is_ref()) + continue; + bool same = !(seg->args[1].kind == adacpp::step::Kind::Enum && seg->args[1].s == "F"); + std::vector sp = curve_points2d(seg->args[2].i); + if (!same) + std::reverse(sp.begin(), sp.end()); + for (const Vec3 &q : sp) + push(q); + } + } else if (iequals(in->type, "IFCTRIMMEDCURVE")) { + for (const Vec3 &q : trimmed_curve_points2d(*in)) + push(q); } if (pts.size() > 1 && (pts.front() - pts.back()).norm() < 1e-9) pts.pop_back(); return pts; } + // Sample an IfcTrimmedCurve (BasisCurve, Trim1, Trim2, SenseAgreement, MasterRepresentation) as a + // 2D profile-outline polyline. Supports an IfcLine basis (straight span) and an IfcCircle basis + // (arc; PARAMETER trims are angles in the model plane-angle unit). CARTESIAN trims give the + // endpoints directly. SenseAgreement=.F. reverses the traversal direction. + std::vector trimmed_curve_points2d(const Instance &in) { + std::vector out; + const Instance *basis = inst(ref_arg(in, 0)); + if (!basis) + return out; + bool sense = !(in.args.size() > 3 && in.args[3].kind == adacpp::step::Kind::Enum && in.args[3].s == "F"); + // Trim1/Trim2 are SETs of IfcTrimmingSelect: an IfcParameterValue ([Keyword, List[Real]] or a + // bare number) and/or an IfcCartesianPoint ref. + auto read_trim = [&](const Value &set, double ¶m, bool &has_p, Vec3 &cart, bool &has_c) { + has_p = has_c = false; + if (!set.is_list()) + return; + for (const Value &e : set.items) { + if (numeric(e)) { + param = e.as_double(); + has_p = true; + } else if (e.is_list() && !e.items.empty() && numeric(e.items[0])) { + param = e.items[0].as_double(); + has_p = true; + } else if (e.is_ref()) { + const Instance *cp = inst(e.i); + if (cp && iequals(cp->type, "IFCCARTESIANPOINT")) { + cart = point(e.i); + has_c = true; + } + } + } + }; + double t1 = 0, t2 = 0; + bool hp1 = false, hp2 = false, hc1 = false, hc2 = false; + Vec3 c1{}, c2{}; + if (in.args.size() > 1) + read_trim(in.args[1], t1, hp1, c1, hc1); + if (in.args.size() > 2) + read_trim(in.args[2], t2, hp2, c2, hc2); + if (iequals(basis->type, "IFCLINE")) { + // IfcLine(Pnt, Dir=IfcVector(Orientation, Magnitude)); P(u) = Pnt + u*Magnitude*Orientation. + Vec3 p0 = point(ref_arg(*basis, 0)); + const Instance *vec = inst(ref_arg(*basis, 1)); + Vec3 od = vec ? dir(ref_arg(*vec, 0)) : Vec3{1, 0, 0}; + double mag = (vec && vec->args.size() > 1 && numeric(vec->args[1])) ? vec->args[1].as_double() : 1.0; + auto at = [&](double u, bool hc, const Vec3 &cp) -> Vec3 { + return hc ? cp : Vec3{p0.x + u * mag * od.x, p0.y + u * mag * od.y, 0}; + }; + Vec3 a = at(t1, hc1, c1), b = at(t2, hc2, c2); + if (!sense) + std::swap(a, b); + out = {a, b}; + } else if (iequals(basis->type, "IFCCIRCLE")) { + Vec3 center{0, 0, 0}; + double phi0 = 0.0, r = ad(basis, 1); + const Instance *pos = inst(ref_arg(*basis, 0)); // IfcAxis2Placement2D(Location, RefDirection) + if (pos) { + if (pos->args.size() > 0 && pos->args[0].is_ref()) + center = point(pos->args[0].i); + if (pos->args.size() > 1 && pos->args[1].is_ref()) { + Vec3 rd = dir(pos->args[1].i); + phi0 = std::atan2(rd.y, rd.x); + } + } + double sc = plane_angle_scale(); + double a1 = t1 * sc, a2 = t2 * sc; // angles measured from the placement's local X axis + if (sense) { // CCW from a1 to a2 + while (a2 <= a1 + 1e-9) + a2 += 2 * PI; + } else { // CW from a1 to a2 + while (a2 >= a1 - 1e-9) + a2 -= 2 * PI; + } + int n = std::max(2, (int) std::ceil(std::abs(a2 - a1) / (PI / 32))); + for (int i = 0; i <= n; ++i) { + double a = phi0 + a1 + (a2 - a1) * i / n; + out.push_back({center.x + r * std::cos(a), center.y + r * std::sin(a), 0}); + } + } + return out; + } // Translate/rotate a profile polygon by an IfcAxis2Placement2D (Location, RefDirection); $ = identity. void apply_placement2d(long pid, std::vector &poly) { const Instance *in = inst(pid); From f429a016a4935c3d6078791801d038e0b24ba1df Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 16:47:36 +0200 Subject: [PATCH 22/65] fix: don't count degenerate/container products as unsupported skips (recognized_empty) products_skipped conflated "unsupported geometry (needs OCC fallback)" with "recognized but intentionally empty". NgeomRoot gains recognized_empty; the stream skips it WITHOUT incrementing products_skipped (so it drives no wasted OCC fallback). Set in two cases the oracle also yields nothing for: - zero-length DISCONTINUOUS IfcCurveSegment (alignment end-markers; ifcopenshell create_shape raises "Failed to process shape" on them too) -> the 2 skips in fixed-ref / sectioned. - a product with no resolvable Representation (IfcElementAssembly container reached via the is_product_type fast path) -> the 1 skip in reinforcing-assembly. Corpus 21/26 -> 24/26 fully covered; the remaining 2 are a gzip container + a degenerate texture-only fixture, not analytic-geometry gaps. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 5 ++++- src/cad/ifc_reader.h | 9 ++++++++- src/geom/neutral/ngeom_topology.h | 5 +++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 297457a..e15d4f4 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -646,7 +646,10 @@ class IfcNgeomStream { bool has_solid = root.extrusion || root.revolve || root.boolean || root.sphere || root.sweep || !root.polylines.empty(); if (root.faces.empty() && !has_solid) { - ++skipped_; + // recognized_empty => a degenerate product we DID understand (zero-length curve marker); + // don't count it as an unsupported skip (that would drive a wasted OCC fallback). + if (!root.recognized_empty) + ++skipped_; continue; } prof_.solid(root.faces.size()); diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index fe562f9..f96fcc5 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -176,8 +176,13 @@ class IfcResolver { // (arg 2) -> IfcShapeRepresentation.Items (arg 3). long rep = ref_arg(*p, 6); const Instance *pds = inst(rep); - if (!pds || pds->args.size() < 3) + if (!pds || pds->args.size() < 3) { + // No (resolvable) Representation -> an intentionally geometry-less container/spatial product + // (e.g. an IfcElementAssembly aggregating rebars, reached via the is_product_type fast path). + // Recognized, not an unsupported skip -> the stream won't waste an OCC fallback on it. + root.recognized_empty = true; return root; + } // IfcShapeRepresentation: (ContextOfItems, RepresentationIdentifier, RepresentationType, Items). // A product may carry several reps: "Body"/"Facetation"/"Tessellation" are the 3D shape; // "Axis" is a reference curve (render only when there is NO body — an alignment segment IS @@ -1748,6 +1753,8 @@ class IfcResolver { std::vector pts = directrix_points(id); if (pts.size() >= 2) root.polylines.push_back(std::move(pts)); + else + root.recognized_empty = true; // recognized curve, but degenerate (e.g. zero-length segment) return; } SolidItemN it = resolve_solid_item(id); diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index e67d469..6bd47eb 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -378,6 +378,11 @@ struct NgeomRoot { std::shared_ptr boolean; // set if this root is a boolean result std::shared_ptr sphere; // set if this root is a sphere primitive std::vector> polylines; // set if this root is a curve-only body (GL_LINES) + // The product's representation WAS recognized but resolved to no drawable geometry because it is + // degenerate (e.g. a zero-length DISCONTINUOUS IfcCurveSegment — a topological end-marker the + // reference kernel also refuses). Distinguishes "intentionally empty" from "unsupported": the + // stream must NOT count these as products_skipped (they'd else drive a pointless OCC fallback). + bool recognized_empty = false; // Presentation colour (STYLED_ITEM -> COLOUR_RGB), populated by the native STEP reader; the // NGEOM byte decoder leaves has_color=false (colour travels out-of-band on that path). bool has_color = false; From 40d4fa66ced45cd9f733c5d88e4644ec14e581cf Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 17:13:40 +0200 Subject: [PATCH 23/65] feat: C++ crows-nest distortion scanner (Mesh.spike_stats + mesh_spike_stats), Python-exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the viewer's client-side tessellation-spike detector (adapy meshStats.ts computeRangeStats) to dependency-free C++ (src/geom/mesh_spike.h) so audits/CI/local Python can scan converted geometry for crows-nest spikes without the browser — one algorithm, browser and server agree. - mesh_spike_stats(positions, indices, aspect_min=8, outlier_k=4) -> {max_spike, spike_tris, triangles}: robust median centroid, median vertex-distance body scale, outlier vertices past outlier_k×, thin (needle) triangles touching them. - Exposed both as Mesh.spike_stats(...) (on a tessellate_stream result) and as a free function over raw numpy (positions, indices) for meshes from any source. Re-exported in adacpp.cad. - Kept OCC/NGEOM-free so it can also compile into the frontend wasm module later (unifying the browser scan with this one). Tests: synthetic spike (max_spike 56.6, 1 spike-tri) vs clean square (1.0, 0); free-function == Mesh.spike_stats on a real tessellated geom. Suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/adacpp/cad/__init__.py | 1 + src/cad/cad_py_wrap.cpp | 35 +++++++++++++ src/geom/mesh_spike.h | 101 ++++++++++++++++++++++++++++++++++++ tests/py/test_mesh_spike.py | 46 ++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 src/geom/mesh_spike.h create mode 100644 tests/py/test_mesh_spike.py diff --git a/src/adacpp/cad/__init__.py b/src/adacpp/cad/__init__.py index 41517c3..b3f0b56 100644 --- a/src/adacpp/cad/__init__.py +++ b/src/adacpp/cad/__init__.py @@ -39,6 +39,7 @@ _step_index_parity = _cad._step_index_parity ifc_taxonomy_settings = _cad.ifc_taxonomy_settings meshopt_simplify_mesh = _cad.meshopt_simplify_mesh +mesh_spike_stats = _cad.mesh_spike_stats meshopt_encode_vertex_buffer = _cad.meshopt_encode_vertex_buffer meshopt_encode_index_sequence = _cad.meshopt_encode_index_sequence meshopt_decode_vertex_buffer = _cad.meshopt_decode_vertex_buffer diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index e15d4f4..8716d5a 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -4,6 +4,7 @@ #include "../geom/GroupReference.h" #include "../geom/Mesh.h" #include "../geom/MeshType.h" +#include "../geom/mesh_spike.h" #include "../cadit/occt/static_param_guard.h" #include "../cadit/occt/step_writer.h" #include "../cadit/ifc/ngeom_taxonomy.h" @@ -3796,10 +3797,44 @@ void cad_module(nb::module_ &m) { return nb::ndarray>( self.edges.data(), {self.edges.size()}, nb::find(self)); }) + .def( + "spike_stats", + [](Mesh &self, double aspect_min, double outlier_k) { + // Crows-nest tessellation-spike scan (same detector as the viewer's client-side + // meshStats.ts) — usable from Python for audits/CI without the browser. + adacpp::geom::SpikeStats s = + adacpp::geom::mesh_spike_stats(self.positions, self.indices, aspect_min, outlier_k); + nb::dict d; + d["max_spike"] = s.max_spike; + d["spike_tris"] = s.spike_tris; + d["triangles"] = s.triangles; + return d; + }, + nb::arg("aspect_min") = 8.0, nb::arg("outlier_k") = 4.0, + "Crows-nest distortion stats {max_spike, spike_tris, triangles} for this tessellated mesh.") .def_ro("mesh_type", &Mesh::mesh_type) .def_ro("color", &Mesh::color) .def_ro("groups", &Mesh::group_reference); + // Free-function form: crows-nest distortion scan over raw (positions, indices) numpy arrays — for + // audits/CI that hold geometry from any source, not only a tessellate_stream Mesh. Same detector + // as Mesh.spike_stats and the viewer's client-side meshStats.ts. + m.def( + "mesh_spike_stats", + [](nb::ndarray> positions, nb::ndarray> indices, + double aspect_min, double outlier_k) { + std::vector p(positions.data(), positions.data() + positions.size()); + std::vector ix(indices.data(), indices.data() + indices.size()); + adacpp::geom::SpikeStats s = adacpp::geom::mesh_spike_stats(p, ix, aspect_min, outlier_k); + nb::dict d; + d["max_spike"] = s.max_spike; + d["spike_tris"] = s.spike_tris; + d["triangles"] = s.triangles; + return d; + }, + nb::arg("positions"), nb::arg("indices"), nb::arg("aspect_min") = 8.0, nb::arg("outlier_k") = 4.0, + "Crows-nest distortion stats {max_spike, spike_tris, triangles} for a flat (positions, indices) mesh."); + nb::class_(m, "StepRootMeta") .def_ro("id", &StepRootMeta::id) .def_ro("guid", &StepRootMeta::guid) diff --git a/src/geom/mesh_spike.h b/src/geom/mesh_spike.h new file mode 100644 index 0000000..b333140 --- /dev/null +++ b/src/geom/mesh_spike.h @@ -0,0 +1,101 @@ +// "Crows-nest" tessellation-spike detector over a triangulated mesh (flat positions + indices). +// +// Mirrors the viewer's client-side detector (adapy frontend src/utils/mesh_select/meshStats.ts — +// computeRangeStats) EXACTLY so a Python/audit scan and the browser inspector agree on what counts +// as distorted. A vertex shot out past the mesh body (the tessellation "crows-nest" bug) is an +// OUTLIER: take the robust (median-per-axis) centroid, the median vertex->centroid distance as the +// body scale, then any vertex beyond outlier_k x that median is a spike. spike_tris counts the +// thin/needle triangles (longest-edge^2 / (2*area) > aspect_min) that touch such an outlier. +// +// Kept dependency-free (no OCC / NGEOM types) so it can also compile into the frontend wasm module. +#pragma once + +#include +#include +#include +#include +#include + +namespace adacpp::geom { + +struct SpikeStats { + double max_spike = 0.0; // worst vertex distance / median (~1 for a compact body; large for a spike) + long spike_tris = 0; // thin triangles touching an outlier vertex + long triangles = 0; // total triangles in the mesh +}; + +// positions: flat xyz (3 floats per vertex). indices: flat (3 per triangle) into the vertices. +inline SpikeStats mesh_spike_stats(const std::vector &pos, const std::vector &idx, + double aspect_min = 8.0, double outlier_k = 4.0) { + SpikeStats s; + s.triangles = static_cast(idx.size() / 3); + if (idx.size() < 3 || pos.empty()) + return s; + + std::unordered_set seen; + for (size_t i = 0; i + 2 < idx.size(); i += 3) { + seen.insert(idx[i]); + seen.insert(idx[i + 1]); + seen.insert(idx[i + 2]); + } + std::vector verts(seen.begin(), seen.end()); + const size_t n = verts.size(); + if (n == 0) + return s; + + std::vector xs(n), ys(n), zs(n); + for (size_t k = 0; k < n; k++) { + const size_t b = static_cast(verts[k]) * 3; + xs[k] = pos[b]; + ys[k] = pos[b + 1]; + zs[k] = pos[b + 2]; + } + auto median = [](std::vector a) -> double { + if (a.empty()) + return 0.0; + std::sort(a.begin(), a.end()); + const size_t m = a.size() / 2; + return a.size() % 2 ? a[m] : 0.5 * (a[m - 1] + a[m]); + }; + const double cx = median(xs), cy = median(ys), cz = median(zs); + + std::vector dists(n); + for (size_t k = 0; k < n; k++) + dists[k] = std::sqrt((xs[k] - cx) * (xs[k] - cx) + (ys[k] - cy) * (ys[k] - cy) + (zs[k] - cz) * (zs[k] - cz)); + const double med_dist = median(dists); + + std::unordered_set outliers; + if (med_dist > 1e-9) { + for (size_t k = 0; k < n; k++) { + const double ratio = dists[k] / med_dist; + if (ratio > s.max_spike) + s.max_spike = ratio; + if (ratio > outlier_k) + outliers.insert(verts[k]); + } + } + + if (!outliers.empty()) { + for (size_t i = 0; i + 2 < idx.size(); i += 3) { + const uint32_t ia = idx[i], ib = idx[i + 1], ic = idx[i + 2]; + if (!outliers.count(ia) && !outliers.count(ib) && !outliers.count(ic)) + continue; + const size_t a = static_cast(ia) * 3, b = static_cast(ib) * 3, + c = static_cast(ic) * 3; + const double abx = pos[b] - pos[a], aby = pos[b + 1] - pos[a + 1], abz = pos[b + 2] - pos[a + 2]; + const double acx = pos[c] - pos[a], acy = pos[c + 1] - pos[a + 1], acz = pos[c + 2] - pos[a + 2]; + const double bcx = pos[c] - pos[b], bcy = pos[c + 1] - pos[b + 1], bcz = pos[c + 2] - pos[b + 2]; + const double crx = aby * acz - abz * acy, cry = abz * acx - abx * acz, crz = abx * acy - aby * acx; + const double tri_area = 0.5 * std::sqrt(crx * crx + cry * cry + crz * crz); + const double abl = std::sqrt(abx * abx + aby * aby + abz * abz); + const double acl = std::sqrt(acx * acx + acy * acy + acz * acz); + const double bcl = std::sqrt(bcx * bcx + bcy * bcy + bcz * bcz); + const double emax = std::max(abl, std::max(acl, bcl)); + if (tri_area > 0.0 && emax * emax > aspect_min * 2.0 * tri_area) + s.spike_tris++; + } + } + return s; +} + +} // namespace adacpp::geom diff --git a/tests/py/test_mesh_spike.py b/tests/py/test_mesh_spike.py new file mode 100644 index 0000000..6c770d0 --- /dev/null +++ b/tests/py/test_mesh_spike.py @@ -0,0 +1,46 @@ +import numpy as np + +import adacpp.cad as C + + +def test_mesh_spike_detects_crows_nest(): + # A compact unit square (2 body triangles) plus one vertex shot far out in +z, + # referenced by a thin needle triangle — the classic tessellation "crows-nest". + pos = np.array( + [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0.5, 0.5, 40.0], + dtype=np.float32, + ) + idx = np.array([0, 1, 2, 0, 2, 3, 0, 1, 4], dtype=np.uint32) + s = C.mesh_spike_stats(pos, idx, 8.0, 4.0) + assert s["triangles"] == 3 + assert s["max_spike"] > 4.0 # the outlier vertex is far past the body + assert s["spike_tris"] >= 1 # the needle triangle touching it + + +def test_mesh_spike_clean_mesh_scores_low(): + # The same square WITHOUT the spike vertex — a compact body scores ~1 and flags nothing. + pos = np.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], dtype=np.float32) + idx = np.array([0, 1, 2, 0, 2, 3], dtype=np.uint32) + s = C.mesh_spike_stats(pos, idx, 8.0, 4.0) + assert s["triangles"] == 2 + assert s["max_spike"] < 4.0 + assert s["spike_tris"] == 0 + + +def test_mesh_spike_stats_matches_mesh_method_on_stream(files_dir): + # The free function and the Mesh.spike_stats method must agree on a real tessellated geom. + import pathlib + + ifcs = sorted(pathlib.Path(files_dir).rglob("with_arc_boundary.ifc")) + if not ifcs: + return # fixture not present in this checkout + st = C.IfcNgeomStream(str(ifcs[0])) + for nbytes, _meta in st: + m = C.tessellate_stream(bytes(nbytes), "libtess2", 0.0, 10.0, {}, 1, 0.0) + idx = np.asarray(m.indices) + if not len(idx): + continue + method = m.spike_stats(8.0, 4.0) + free = C.mesh_spike_stats(np.asarray(m.positions), idx, 8.0, 4.0) + assert method == free + break From 29f50c0f7b6944b2140becacdb7092c0dd1ceb4a Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 17:19:50 +0200 Subject: [PATCH 24/65] feat: IfcResolver resolves presentation colour (IfcStyledItem -> IfcColourRgb) The pure-C++ IFC reader now emits product colour, riding the existing StepRootMeta.has_color/color rails that IfcNgeomStream already copies from the NgeomRoot (previously always uncoloured -> the viewer fell back to grey). Mirrors the STEP reader's colour_map_/find_colour: - build_colour_map(): one-time scan of IFCSTYLEDITEM, keyed on Item=arg0 (the rep-item id resolve_item consumes), value = rgba from resolve_styles_color. - resolve_styles_color(): BFS over the Styles ref-tree to the first IfcColourRgb (r/g/b=args[1..3]) + alpha from IfcSurfaceStyleShading/Rendering transparency (arg1); collects every ref arg so it handles the IFC2x3 PresentationStyleAssignment wrapper and IFC4/4x3 uniformly. - find_item_colour(): looks up a rep item, unwrapping IfcMappedItem (mapped rep items) and IfcBooleanResult/ClippingResult operands (colour rides the base operand). - resolve_product sets root.has_color/cr/cg/cb/ca from the resolved item ids, then solid_src_. Verified vs oracle: box_rotated 2/2 products = (0.8,0.8,0.8) matching its IfcColourRgb. Known gap: half_space_beam styles a SEPARATE tessellated representation disconnected from the product's analytic boolean body (its boolean carries no style) -> stays default. Geometry coverage unchanged (24/26); adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 129 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index f96fcc5..44c6513 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -162,6 +162,105 @@ class IfcResolver { return angle_scale_; } + // Presentation colour. IfcStyledItem(Item, Styles, Name): Item=arg0 is the geometry rep item id + // (the same id resolve_item consumes), Styles=arg1. Build a one-time map rep-item-id -> rgba by + // resolving each styled item's style tree to its IfcColourRgb (+ transparency). Persistent across + // products (clear_cache() must not wipe it). Mirrors the STEP reader's colour_map_/find_colour. + void build_colour_map() { + if (colour_map_built_) + return; + colour_map_built_ = true; + std::string scratch; + for (long id : idx_.ids) { + if (!iequals(type_of(id, scratch), "IFCSTYLEDITEM")) + continue; + const Instance *si = inst(id); + if (!si || si->args.empty() || !si->args[0].is_ref()) + continue; + std::array rgba{0.5f, 0.5f, 0.5f, 1.0f}; + if (resolve_styles_color(si->args.size() > 1 ? si->args[1] : Value{}, rgba)) + colour_map_[si->args[0].i] = rgba; + } + } + // BFS over a Styles ref-tree to the first IfcColourRgb (r/g/b = args[1..3]) + alpha from the + // IfcSurfaceStyleShading/Rendering transparency (arg1). Walks IfcSurfaceStyle (styles=arg2), + // IfcPresentationStyleAssignment (2x3 wrapper), etc. by collecting every ref arg — so it handles + // IFC2x3 and IFC4/4x3 without schema-specific level walking. + bool resolve_styles_color(const Value &styles, std::array &rgba) { + std::vector stack; + auto push_refs = [&](const Value &v) { + if (v.is_ref()) + stack.push_back(v.i); + else if (v.is_list()) + for (const Value &e : v.items) + if (e.is_ref()) + stack.push_back(e.i); + }; + push_refs(styles); + std::unordered_set seen; + bool found = false; + int guard = 0; + while (!stack.empty() && guard++ < 500) { + long id = stack.back(); + stack.pop_back(); + if (!seen.insert(id).second) + continue; + const Instance *in = inst(id); + if (!in) + continue; + std::string_view t = in->type; + if (iequals(t, "IFCCOLOURRGB")) { + rgba[0] = (float) ad(in, 1); + rgba[1] = (float) ad(in, 2); + rgba[2] = (float) ad(in, 3); + found = true; + continue; + } + if (iequals(t, "IFCSURFACESTYLESHADING") || iequals(t, "IFCSURFACESTYLERENDERING")) { + if (in->args.size() > 1 && numeric(in->args[1])) + rgba[3] = 1.0f - (float) in->args[1].as_double(); // Transparency -> alpha + if (!in->args.empty() && in->args[0].is_ref()) + stack.push_back(in->args[0].i); // SurfaceColour + continue; + } + for (const Value &a : in->args) + push_refs(a); // IfcSurfaceStyle / PresentationStyleAssignment / StyledRepresentation ... + } + return found; + } + // Look up a rep item's colour, unwrapping an IfcMappedItem to its mapped representation's items + // (the style may sit on the shared inner geometry). + bool find_item_colour(long iid, std::array &rgba, int depth = 0) { + auto it = colour_map_.find(iid); + if (it != colour_map_.end()) { + rgba = it->second; + return true; + } + if (depth > 4) + return false; + const Instance *in = inst(iid); + if (!in) + return false; + if (iequals(in->type, "IFCMAPPEDITEM")) { + const Instance *rm = inst(ref_arg(*in, 0)); // IfcRepresentationMap + if (rm && rm->args.size() > 1) { + const Instance *sr = inst(ref_arg(*rm, 1)); // MappedRepresentation + if (sr && sr->args.size() > 3 && sr->args[3].is_list()) + for (const Value &m : sr->args[3].items) + if (m.is_ref() && find_item_colour(m.i, rgba, depth + 1)) + return true; + } + } else if (iequals(in->type, "IFCBOOLEANCLIPPINGRESULT") || iequals(in->type, "IFCBOOLEANRESULT")) { + // (Operator, FirstOperand, SecondOperand) — the style usually rides the base operand. + for (int ai : {1, 2}) { + long op = ref_arg(*in, (size_t) ai); + if (op > 0 && find_item_colour(op, rgba, depth + 1)) + return true; + } + } + return false; + } + // Build the NgeomRoot for a product id (faces + name + per-instance transforms). Empty faces => the // product had no resolvable advanced-brep (skipped by the caller). NgeomRoot resolve_product(long pid) { @@ -210,6 +309,7 @@ class IfcResolver { } } } + std::vector item_ids; // the rep-item ids (IfcStyledItem keys colour on these) for (const Value &srref : pds->args[2].items) { const Instance *sr = inst(srref.i); if (!sr || sr->args.size() < 4) @@ -222,9 +322,36 @@ class IfcResolver { for (const Value &item : sr->args[3].items) { if (!item.is_ref()) continue; + item_ids.push_back(item.i); resolve_item(item.i, root); } } + // Presentation colour (IfcStyledItem -> IfcSurfaceStyle -> IfcColourRgb) — keyed on the rep + // item id, so it rides the same StepRootMeta.has_color/color rails the STEP path already uses. + build_colour_map(); + if (!colour_map_.empty()) { + std::array rgba; + long keys[] = {solid_src_, 0}; + for (long iid : item_ids) + if (find_item_colour(iid, rgba)) { + root.has_color = true; + root.cr = rgba[0]; + root.cg = rgba[1]; + root.cb = rgba[2]; + root.ca = rgba[3]; + break; + } + if (!root.has_color) + for (long k : keys) + if (k > 0 && find_item_colour(k, rgba)) { + root.has_color = true; + root.cr = rgba[0]; + root.cg = rgba[1]; + root.cb = rgba[2]; + root.ca = rgba[3]; + break; + } + } // A product mixing >1 distinct solid (or brep+procedural) doesn't fit the single-solid root // model — leave it for OCC rather than emit partial geometry. if (mixed_) { @@ -280,6 +407,8 @@ class IfcResolver { long solid_src_ = 0; // entity id of the one solid this product carries (mapped instances share it) bool mixed_ = false; // product has >1 distinct solid / mixes brep+procedural -> skip (OCC) double angle_scale_ = 0.0; // radians per model plane-angle unit (0 => not yet computed) + std::unordered_map> colour_map_; // rep-item id -> rgba (IfcStyledItem) + bool colour_map_built_ = false; const Instance *inst(long id) { if (id <= 0) From aa806e9ba220bb3fd18a708651eaf62f32cc3bdd Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 17:22:33 +0200 Subject: [PATCH 25/65] feat: IfcResolver emits spatial-structure path (instance_paths) for the scene tree Completes the resolver metadata trio (geometry + colour + hierarchy) so a future native IFC->GLB can build the same spatial tree from_ifc gives today. Mirrors the STEP reader's path emission onto the existing StepRootMeta.instance_paths rails (already copied by IfcNgeomStream::next): - build_rel_maps(): one-time reverse maps from IfcRelContainedInSpatialStructure (RelatedElements arg4 -> RelatingStructure arg5) and IfcRelAggregates (RelatedObjects arg5 -> RelatingObject arg4). - product_path(): walk a product up (contained-in, then aggregates) to IfcProject, emit root-first (id, name) levels as one instance_paths entry; only when a real ancestor chain exists. Verified: box_rotated -> AdaProject > base_library_concept > Topology > DOORS > door1; reinforcing -> Project > Building > ... > bar. Persistent maps survive clear_cache. adacpp suites green (65), geometry coverage unchanged (24/26). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 44c6513..74e187f 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -261,6 +261,63 @@ class IfcResolver { return false; } + // Spatial hierarchy. IfcRelContainedInSpatialStructure(.., RelatedElements=arg4 list, + // RelatingStructure=arg5) puts products in a storey/space; IfcRelAggregates(.., RelatingObject=arg4, + // RelatedObjects=arg5 list) nests storey->building->site->project (and sub-assemblies). Build both + // reverse maps once (persistent; clear_cache must not wipe them) so a product can walk up to root. + void build_rel_maps() { + if (rel_maps_built_) + return; + rel_maps_built_ = true; + std::string scratch; + for (long id : idx_.ids) { + std::string_view t = type_of(id, scratch); + if (iequals(t, "IFCRELCONTAINEDINSPATIALSTRUCTURE")) { + const Instance *in = inst(id); + if (!in || in->args.size() < 6) + continue; + long structure = ref_arg(*in, 5); + if (in->args[4].is_list()) + for (const Value &e : in->args[4].items) + if (e.is_ref()) + contained_of_[e.i] = structure; + } else if (iequals(t, "IFCRELAGGREGATES")) { + const Instance *in = inst(id); + if (!in || in->args.size() < 6) + continue; + long parent = ref_arg(*in, 4); + if (in->args[5].is_list()) + for (const Value &e : in->args[5].items) + if (e.is_ref()) + parent_of_[e.i] = parent; + } + } + } + // Root-first (id, name) levels from IfcProject down to the product — the assembly path the adapy + // consumer turns into the scene tree. One path (IFC products are single-instance here). + std::vector> product_path(long pid, const Instance &p) { + build_rel_maps(); + auto next_up = [&](long id) -> long { + auto a = parent_of_.find(id); + if (a != parent_of_.end()) + return a->second; + auto c = contained_of_.find(id); + return c != contained_of_.end() ? c->second : 0; + }; + std::vector> path; + path.push_back({(int) pid, name_of(p)}); // deepest level = the product itself + std::unordered_set seen{pid}; + long up = next_up(pid); + int guard = 0; + while (up > 0 && seen.insert(up).second && guard++ < 64) { + const Instance *s = inst(up); + path.push_back({(int) up, s ? name_of(*s) : std::string()}); + up = next_up(up); + } + std::reverse(path.begin(), path.end()); // root-first + return path; + } + // Build the NgeomRoot for a product id (faces + name + per-instance transforms). Empty faces => the // product had no resolvable advanced-brep (skipped by the caller). NgeomRoot resolve_product(long pid) { @@ -352,6 +409,13 @@ class IfcResolver { break; } } + // Spatial-structure path (Project -> Site -> Storey -> product), for the scene tree. Only emit + // when there's a real ancestor chain (size>1); a bare product level carries no hierarchy. + { + auto path = product_path(pid, *p); + if (path.size() > 1) + root.instance_paths.push_back(std::move(path)); + } // A product mixing >1 distinct solid (or brep+procedural) doesn't fit the single-solid root // model — leave it for OCC rather than emit partial geometry. if (mixed_) { @@ -409,6 +473,9 @@ class IfcResolver { double angle_scale_ = 0.0; // radians per model plane-angle unit (0 => not yet computed) std::unordered_map> colour_map_; // rep-item id -> rgba (IfcStyledItem) bool colour_map_built_ = false; + std::unordered_map contained_of_; // product -> containing spatial element (IfcRelContained…) + std::unordered_map parent_of_; // child -> aggregating parent (IfcRelAggregates) + bool rel_maps_built_ = false; const Instance *inst(long id) { if (id <= 0) From 4a4e5e03dc2aeb3513f23bcdb0f959022f3197ee Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 17:35:07 +0200 Subject: [PATCH 26/65] feat: native IFC writer emits presentation colour (IfcStyledItem -> IfcColourRgb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native STEP->IFC writer dropped colour (v1 note "carries no colour") even though the NgeomRoot already carries it — the STEP reader resolves STYLED_ITEM->COLOUR_RGB into has_color/cr/cg/cb/ca, and the IFC reader now does too. emit_solid_ifc now emits IfcColourRgb + IfcSurfaceStyleShading + IfcSurfaceStyle + IfcStyledItem keyed on the shared IfcAdvancedBrep (so it colours every mapped instance). Closes the #1 writer gap vs adapy's to_ifc / the Python streaming writer — the viewer no longer falls back to grey. Verified: flat_plate_wColors STEP->IFC emits 2 styled items = (0.098,0.098,0.098) matching the source COLOUR_RGB exactly, keyed on IfcAdvancedBrep, ifcopenshell.validate 0 errors (IFC4X3). adacpp suites green (65); adapy native-IFC-emit suite green (43). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 8716d5a..a8ce5f5 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -2806,6 +2806,17 @@ static void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, long brep = em.emit_advanced_brep(buf, root); if (!brep) return; + // Presentation colour: style the shared brep item so it colours every (mapped) instance. The + // NgeomRoot already carries the colour resolved by the STEP/IFC reader (has_color/cr/cg/cb/ca) — + // emit it as IfcStyledItem -> IfcSurfaceStyle -> IfcColourRgb (was dropped; the viewer fell back + // to grey). Matches adapy's add_colour + the Python streaming writer. + if (root.has_color) { + auto R = [](float v) { return adacpp::ifc_emit::ifc_real(v); }; + long col = em.emit_entity(buf, "IfcColourRgb($," + R(root.cr) + "," + R(root.cg) + "," + R(root.cb) + ")"); + long sh = em.emit_entity(buf, "IfcSurfaceStyleShading(#" + std::to_string(col) + "," + R(1.0f - root.ca) + ")"); + long ss = em.emit_entity(buf, "IfcSurfaceStyle($,.BOTH.,(#" + std::to_string(sh) + "))"); + em.emit_entity(buf, "IfcStyledItem(#" + std::to_string(brep) + ",(#" + std::to_string(ss) + "),$)"); + } std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : ifc_str(root.id); auto record = [&](long proxy, size_t k) { proxies.push_back(proxy); From acc76a3b740524eb068c895e0cd3fc005706055d Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 17:46:21 +0200 Subject: [PATCH 27/65] feat: read gzip-compressed IFC/STEP input (StreamIndex inflates before indexing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .ifc/.stp may be gzip-compressed on disk (beam-standard-case-gz.ifc). gzip isn't seekable, so StreamIndex now detects the 0x1f 0x8b magic and inflates the whole file into an owned buffer, then indexes it in memory (fd/mmap/pread fast paths bypassed for gz). Handled in all three entry points (in-memory ctor, from_file mmap, from_file_pread) + move-assignment re-points buf_ at the moved owned_ buffer. zlib is linked ONLY to the Python ext (ADA_CPP_LINK_LIBS + ZLIB::ZLIB) with a target-scoped ADACPP_HAVE_ZLIB define (build_python.cmake, guarded on ZLIB_FOUND) — the minimal STP2GLB CLI, the C++ test targets, and the wasm build compile gunzip() as a no-op stub (returns empty -> 0 products, never a crash), so none need a zlib link. zlib is already in the pixi test/prod envs. Corpus 24/26 -> 25/26 (beam-standard-case-gz now 18/18 products, 0 skipped); the only remaining file is a degenerate texture-only fixture (0 products). adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 15 +++++++ cmake/build_python.cmake | 7 +++ src/cadit/step/step_reader.h | 86 +++++++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 03083fd..f6a6283 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -201,6 +201,21 @@ include(cmake/deps_manifold.cmake) list(APPEND ADA_CPP_LINK_LIBS manifold) add_compile_definitions(ADACPP_HAVE_MANIFOLD) +# zlib for gzip-compressed IFC/STEP input (StreamIndex inflates before indexing). Native only — the +# pixi test/prod envs carry zlib; the wasm build skips gzip (gunzip() is a no-op without the define, +# returning 0 statements rather than crashing). The ADACPP_HAVE_ZLIB define is applied ONLY to the +# Python ext target (build_python.cmake, guarded on ZLIB_FOUND) — NOT globally — so the minimal +# STP2GLB CLI and the C++ test targets (which don't link zlib) compile gunzip() as the no-op stub. +if (NOT BUILD_WASM) + find_package(ZLIB) + if (ZLIB_FOUND) + list(APPEND ADA_CPP_LINK_LIBS ZLIB::ZLIB) + message(STATUS "zlib found: gzip-compressed IFC/STEP input enabled (Python ext)") + else () + message(STATUS "zlib NOT found: gzip-compressed input disabled") + endif () +endif () + if (BUILD_STP2GLB) message(STATUS "Building the STP2GLB executable") include(cmake/build_stp2glb.cmake) diff --git a/cmake/build_python.cmake b/cmake/build_python.cmake index df9ba92..ae901b3 100644 --- a/cmake/build_python.cmake +++ b/cmake/build_python.cmake @@ -47,6 +47,13 @@ nanobind_add_module(_ada_cpp_ext_impl STABLE_ABI ${ADA_CPP_SOURCES} ${ADA_CPP_PY # Link libraries to the module target_link_libraries(_ada_cpp_ext_impl PRIVATE ${ADA_CPP_LINK_LIBS}) +# gzip-compressed IFC/STEP input: enable StreamIndex's zlib inflate ONLY on this target (it links +# ZLIB::ZLIB via ADA_CPP_LINK_LIBS). Other targets compile gunzip() as the no-op stub, so the +# minimal STP2GLB CLI / C++ tests need no zlib link. ZLIB_FOUND is set in the top-level CMakeLists. +if (ZLIB_FOUND) + target_compile_definitions(_ada_cpp_ext_impl PRIVATE ADACPP_HAVE_ZLIB) +endif () + # Wasm builds: statically link the OCCT toolkits cross-built by wasm_occt.cmake. # The OCCT IMPORTED targets carry their include dir as INTERFACE_INCLUDE_DIRECTORIES, # so adding them here also makes & friends resolve. diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index c456ae3..cd7bf06 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -33,6 +33,10 @@ #include "../../cad/posix_compat.h" +#ifdef ADACPP_HAVE_ZLIB +#include +#endif + #include "ngeom_bspline.h" // BSplineCurve / BSplineSurface / expand_knots #include "ngeom_topology.h" // pulls ngeom_curves.h / ngeom_surfaces.h / ngeom_math.h #include "step_part21.h" @@ -165,9 +169,16 @@ class StreamIndex { std::vector offs; // statement-start offset, parallel to ids TypeLists lists; - // In-memory: the caller keeps `b` alive; statement_bytes returns views into it. - explicit StreamIndex(std::string_view b) : buf_(b) { - scan(b, nullptr); + // In-memory: the caller keeps `b` alive; statement_bytes returns views into it. gzip bytes are + // inflated into owned_ first (buf_ then views the inflated text). + explicit StreamIndex(std::string_view b) { + if (is_gzip(b.data(), b.size())) { + owned_ = gunzip(b.data(), b.size()); + buf_ = owned_; + } else { + buf_ = b; + } + scan(buf_, nullptr); } // File-backed, pread-only (NO mmap): for environments where mmap can't lazily page a large file @@ -185,6 +196,21 @@ class StreamIndex { return si; } si.fsize_ = (size_t) st.st_size; + char magic[2] = {0, 0}; + if (::pread(si.fd_, magic, 2, 0) == 2 && is_gzip(magic, 2)) { + // gzip: read the whole compressed file, inflate, index in memory (no seekable fast path). + std::string comp; + comp.resize(si.fsize_); + ssize_t r = ::pread(si.fd_, comp.data(), si.fsize_, 0); + ::close(si.fd_); + si.fd_ = -1; + if (r == (ssize_t) si.fsize_) { + si.owned_ = gunzip(comp.data(), comp.size()); + si.buf_ = si.owned_; + si.scan(si.buf_, nullptr); + } + return si; + } si.scan_pread(); return si; } @@ -208,6 +234,16 @@ class StreamIndex { si.fd_ = -1; return si; } + if (is_gzip((const char *) m, si.fsize_)) { + // gzip isn't seekable -> inflate fully, drop the fd/mmap, index the inflated text in memory. + si.owned_ = gunzip((const char *) m, si.fsize_); + ::munmap(m, si.fsize_); + ::close(si.fd_); + si.fd_ = -1; + si.buf_ = si.owned_; + si.scan(si.buf_, nullptr); + return si; + } ::madvise(m, si.fsize_, MADV_SEQUENTIAL); si.scan(std::string_view((const char *) m, si.fsize_), m); // free-behind during the scan ::munmap(m, si.fsize_); @@ -228,7 +264,10 @@ class StreamIndex { ids = std::move(o.ids); offs = std::move(o.offs); lists = std::move(o.lists); - buf_ = o.buf_; + owned_ = std::move(o.owned_); + // If the source owned its bytes (inflated gzip), buf_ must view OUR moved buffer, not the + // source's now-empty one; otherwise carry the external view across verbatim. + buf_ = owned_.empty() ? o.buf_ : std::string_view(owned_); fd_ = o.fd_; fsize_ = o.fsize_; o.fd_ = -1; @@ -569,9 +608,46 @@ class StreamIndex { lists.units.push_back(id); } - std::string_view buf_; // in-memory mode (empty in file mode) + std::string_view buf_; // in-memory mode (view; may point into owned_ for inflated gzip input) + std::string owned_; // owns inflated bytes when the input was gzip (buf_ then views this) int fd_ = -1; // file mode (>=0) size_t fsize_ = 0; + + // gzip magic (RFC 1952): 0x1f 0x8b. A .ifc/.stp may be gzip-compressed on disk. + static bool is_gzip(const char *d, size_t n) { + return n >= 2 && (unsigned char) d[0] == 0x1f && (unsigned char) d[1] == 0x8b; + } + // Inflate a whole gzip buffer to text. Empty on failure or when zlib isn't linked (the caller then + // sees 0 statements -> products_skipped, never a crash). gzip is not seekable, so it must be fully + // inflated up front and indexed in-memory (no mmap/pread fast path). + static std::string gunzip(const char *data, size_t n) { +#ifdef ADACPP_HAVE_ZLIB + z_stream zs{}; + if (inflateInit2(&zs, 15 + 16) != Z_OK) // 15 window bits + 16 => gzip header + return {}; + zs.next_in = (Bytef *) data; + zs.avail_in = (uInt) n; + std::string out; + char tmp[1u << 16]; + int ret; + do { + zs.next_out = (Bytef *) tmp; + zs.avail_out = sizeof(tmp); + ret = inflate(&zs, Z_NO_FLUSH); + if (ret != Z_OK && ret != Z_STREAM_END) { + inflateEnd(&zs); + return {}; + } + out.append(tmp, sizeof(tmp) - zs.avail_out); + } while (ret != Z_STREAM_END); + inflateEnd(&zs); + return out; +#else + (void) data; + (void) n; + return {}; +#endif + } }; class Resolver { From 06ba4c1d66b2ef98fa3bdb9c16a53ab0b24ce42d Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 18:05:36 +0200 Subject: [PATCH 28/65] feat: native pure-C++ IFC -> GLB (stream_ifc_to_glb), no ifcopenshell/OCC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader now emits geometry + colour + spatial hierarchy + guid, and (confirmed) the viewer's GLB only needs geometry + colour + hierarchy + names — IFC property sets never live in the GLB (fetched on selection from the source). So a fully native IFC->GLB is viewer-equivalent. src/cad/ifc_to_glb_stream.h: stream_ifc_to_glb mirrors stream_step_to_glb but drives IfcResolver (proxy_roots / resolve_product / unit_scale) instead of the STEP Resolver, feeding the SAME shared C++ GLB spill writer (ngeom_glb.h write_glb_merged) — StreamIndex -> IfcResolver -> libtess2 -> GLB, baked to metres, merge-by-colour. Single-threaded v1 (IfcResolver's colour/rel maps are per-instance; parallelising needs a metadata share). Curve-only bodies (alignment axes -> LINES) skipped (GlbSolid is triangles-only). Bound as adacpp.cad.stream_ifc_to_glb. Verified: box_rotated GLB structurally identical to the STEP native path (same node/mesh/material layout), material baseColor 0.8 == source, baked to metres, valid glTF. reinforcing-assembly = 35 products, 11.8MB meshopt GLB. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/adacpp/cad/__init__.py | 1 + src/cad/cad_py_wrap.cpp | 16 ++++++ src/cad/ifc_to_glb_stream.h | 108 ++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 src/cad/ifc_to_glb_stream.h diff --git a/src/adacpp/cad/__init__.py b/src/adacpp/cad/__init__.py index b3f0b56..2e65151 100644 --- a/src/adacpp/cad/__init__.py +++ b/src/adacpp/cad/__init__.py @@ -24,6 +24,7 @@ tessellate_stream = _cad.tessellate_stream stream_step_to_meshes = _cad.stream_step_to_meshes stream_step_to_glb = _cad.stream_step_to_glb +stream_ifc_to_glb = _cad.stream_ifc_to_glb stream_step_to_mesh = _cad.stream_step_to_mesh step_emit_ifc_brep = _cad.step_emit_ifc_brep stream_step_to_step = _cad.stream_step_to_step diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index a8ce5f5..bbf76a2 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -15,6 +15,7 @@ #include "../geom/neutral/ngeom_profile.h" #include "step_to_glb_st.h" // single-threaded, mmap-free STEP->GLB core (wasm/OPFS + native oracle) #include "step_to_glb_stream.h" // threaded OCC-free STEP->GLB core (shared with the STP2GLB CLI) +#include "ifc_to_glb_stream.h" // native OCC-free IFC->GLB core (IfcResolver) #include "step_to_mesh_stream.h" // threaded OCC-free STEP->STL/OBJ core (parallel, baked, streaming) #include "ifc_emit.h" // native IFC4 advanced-B-rep emitter (Phase 1, native STEP->IFC writer) #include "step_emit.h" // native AP242 STEP advanced-B-rep emitter (native STEP->STEP writer) @@ -739,6 +740,14 @@ int stream_step_to_glb_impl(const std::string &in_path, const std::string &out_p /*spill_dir=*/"", model_scale); } +// Native OCC-free IFC -> GLB (IfcResolver: geometry + colour + spatial tree, baked to metres). v1 +// single-threaded. Returns the number of products written, or -1 on error. +int stream_ifc_to_glb_impl(const std::string &in_path, const std::string &out_path, double deflection, + double angular_deg, bool meshopt, double model_scale) { + return (int) adacpp::stream_ifc_to_glb(in_path, out_path, deflection, angular_deg, meshopt, + /*spill_dir=*/"", model_scale); +} + // Threaded OCC-free STEP -> STL / OBJ (same reader + parallel tessellation as the GLB core, but bakes // world placements and streams triangles to a binary STL or Wavefront OBJ). Returns the triangle // count, or -1 on error. `fmt` is "stl" or "obj". @@ -4084,6 +4093,13 @@ void cad_module(nb::module_ &m) { "(default) bakes EXT_meshopt_compression inline (no Python re-pack). Returns the number of " "solids written (-1 on I/O error). angular_deg in degrees."); + m.def("stream_ifc_to_glb", &stream_ifc_to_glb_impl, "in_path"_a, "out_path"_a, "deflection"_a = 0.0, + "angular_deg"_a = 20.0, "meshopt"_a = true, "model_scale"_a = 0.0, + "Native IFC -> GLB file (no ifcopenshell, no OCC): IfcResolver resolves each product's " + "geometry + presentation colour + spatial-structure path, libtess2 tessellates, baked to " + "metres into a merge-by-colour GLB matching the viewer. Single-threaded v1; curve-only " + "bodies (alignment axes) skipped. Returns products written (-1 on error)."); + m.def("stream_step_to_mesh", &stream_step_to_mesh_impl, "in_path"_a, "out_path"_a, "fmt"_a, "deflection"_a = 2.0, "angular_deg"_a = 20.0, "num_threads"_a = 0, "model_scale"_a = 0.0, "Native STEP -> STL/OBJ file: the SAME native reader + parallel tessellation as " diff --git a/src/cad/ifc_to_glb_stream.h b/src/cad/ifc_to_glb_stream.h new file mode 100644 index 0000000..e4a5891 --- /dev/null +++ b/src/cad/ifc_to_glb_stream.h @@ -0,0 +1,108 @@ +// Native pure-C++ IFC -> GLB (no ifcopenshell, no OCC). IfcResolver resolves each product to an +// NgeomRoot (geometry + presentation colour + spatial-structure path + length unit), libtess2 +// tessellates it, and the SHARED GLB spill writer (ngeom_glb.h — the same one stream_step_to_glb +// uses) bakes it to metres. So the viewer gets geometry + per-mesh colour + the spatial tree + +// names/guids — a viewer-equivalent GLB (IFC property sets never live in the GLB; they are fetched +// on selection from the source model). +// +// Mirrors stream_step_to_glb but drives IfcResolver (proxy_roots / resolve_product / unit_scale) +// instead of the STEP Resolver. Single-threaded v1: IfcResolver's colour/rel maps are lazily built +// per instance, so parallelising needs a copy_metadata_from-style share (follow-up). Curve-only +// bodies (alignment axes -> GL_LINES) are skipped here (GlbSolid is triangles-only). +#pragma once + +#include +#include +#include +#include + +#include "../geom/neutral/ada_ext_schema.h" +#include "../geom/neutral/ngeom_glb.h" +#include "../geom/neutral/ngeom_tessellate.h" +#include "ifc_reader.h" +#include "step_reader.h" // StreamIndex + +namespace adacpp { + +inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out_path, double deflection, + double angular_deg, bool meshopt, const std::string &spill_dir = "", + double model_scale = 0.0) { + using namespace adacpp::ngeom; + adacpp::step::StreamIndex idx = adacpp::step::StreamIndex::from_file(in_path); + if (!idx.ok()) + return -1; + adacpp::ifc_read::IfcResolver r(idx); + std::vector roots = r.proxy_roots(); + + TessParams tp; + tp.deflection = deflection; + tp.max_angle = angular_deg * PI / 180.0; + tp.threads = 1; + tp.model_scale = model_scale; + const double usc = r.unit_scale(); // metres per file length-unit -> bake to metres (viewer default) + + // Spill dir: private mkdtemp (auto-removed) unless the caller supplied one. + std::string spill; + bool remove_after = false; + char tmpl[] = "/tmp/adacpp_ifcglb_XXXXXX"; + if (spill_dir.empty()) { + if (char *dir = ::mkdtemp(tmpl)) { + spill = dir; + remove_after = true; + } + } else { + std::error_code ec; + std::filesystem::create_directories(spill_dir, ec); + if (std::filesystem::is_directory(spill_dir, ec)) + spill = spill_dir; + } + if (spill.empty()) + return -1; + + long nwritten = 0; + bool ok = false; + { // lane scoped so its temp files are gone before the (maybe) rmdir + adacpp::glb::GlbSpillWriter lane(spill, 0); + for (long pid : roots) { + NgeomRoot root = r.resolve_product(pid); + r.clear_cache(); // bounded memory: statement/surface caches don't grow across products + NgeomDoc one; + one.roots.push_back(std::move(root)); + TessMesh tm = tessellate_doc(one, tp); + if (tm.indices.empty()) + continue; + if (tm.mesh_type == MeshType::LINES) + continue; // curve-only body (alignment axis); GlbSolid is triangles-only in v1 + const NgeomRoot &rr = one.roots[0]; + adacpp::glb::GlbSolid gs; + gs.positions = std::move(tm.positions); + gs.indices = std::move(tm.indices); + gs.color = {rr.cr, rr.cg, rr.cb, rr.ca}; // grey default when !has_color + gs.transforms = rr.transforms; + gs.id = rr.id; + if (!rr.instance_paths.empty() && !rr.instance_paths[0].empty()) + gs.product_name = rr.instance_paths[0].back().second; + gs.instance_paths = rr.instance_paths; + if (usc != 1.0) { + const float s = (float) usc; + for (float &p : gs.positions) + p *= s; + for (auto &M : gs.transforms) { + M[12] *= s; + M[13] *= s; + M[14] *= s; + } + } + lane.add(gs); + ++nwritten; + } + std::vector lane_ptrs{&lane}; + const std::string ada_ext = adacpp::ada_ext::AdaDesignAndAnalysisExtension{}.to_json(); + ok = adacpp::glb::write_glb_merged(out_path, lane_ptrs, ada_ext, meshopt); + } + if (remove_after) + ::rmdir(spill.c_str()); + return ok ? nwritten : -1; +} + +} // namespace adacpp From c0266ccd84a9bf3d9ce50d751eae36be74fbb398 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 18:19:09 +0200 Subject: [PATCH 29/65] fix: native IFC hierarchy/picking uses GlobalId when Name is empty (matches from_ifc GLB) The instance_paths tree + picking leaves were named by the IFC Name attribute, which is empty for rebar and assemblies (they carry only a GlobalId) -> the viewer got a tree of blank nodes and empty selection info. New name_or_guid() falls back to the GlobalId (arg0) when Name (arg2) is empty, for both the product leaf (root.id) and every spatial-path level. Mirrors the Python from_ifc GLB, which keys the id_hierarchy on the guid. Verified vs the Python path on reinforcing-assembly: id_hierarchy now 37/38 GUIDs identical, 0 empty-name nodes (was all-empty). adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 74e187f..564092c 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -305,13 +305,13 @@ class IfcResolver { return c != contained_of_.end() ? c->second : 0; }; std::vector> path; - path.push_back({(int) pid, name_of(p)}); // deepest level = the product itself + path.push_back({(int) pid, name_or_guid(p)}); // deepest level = the product itself std::unordered_set seen{pid}; long up = next_up(pid); int guard = 0; while (up > 0 && seen.insert(up).second && guard++ < 64) { const Instance *s = inst(up); - path.push_back({(int) up, s ? name_of(*s) : std::string()}); + path.push_back({(int) up, s ? name_or_guid(*s) : std::string()}); up = next_up(up); } std::reverse(path.begin(), path.end()); // root-first @@ -327,7 +327,7 @@ class IfcResolver { const Instance *p = inst(pid); if (!p) return root; - root.id = name_of(*p); + root.id = name_or_guid(*p); // IfcBuildingElementProxy.Representation (arg 6) -> IfcProductDefinitionShape.Representations // (arg 2) -> IfcShapeRepresentation.Items (arg 3). long rep = ref_arg(*p, 6); @@ -525,6 +525,18 @@ class IfcResolver { return std::string(in.args[2].s); return {}; } + // Display/picking id for a rooted entity: its Name (arg2) if set, else its GlobalId (arg0). Rebar + // and assemblies frequently carry only a GlobalId (empty Name) -> naming the tree/picking by Name + // alone yields blank nodes + empty selection; the Python from_ifc GLB keys on the guid instead, so + // this mirrors it. Empty only when the entity has neither. + static std::string name_or_guid(const Instance &in) { + std::string n = name_of(in); + if (!n.empty()) + return n; + if (!in.args.empty() && in.args[0].kind == adacpp::step::Kind::Str) + return std::string(in.args[0].s); + return {}; + } // -- geometry ----------------------------------------------------------- Vec3 point(long id) { From 339224221d83e72f1f3dc039d0d76f488e15f66c Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 19:30:14 +0200 Subject: [PATCH 30/65] feat: weld vertices + crease-angle smooth normals per root (all geom types) The tessellator emits flat-shaded triangle SOUP (emit_tri gives 3 fresh verts/tri, each with the triangle's face normal) -> analytic solids (extrusion/sweep/swept-disk) had 3 verts/tri where OCC/ ifcopenshell produce a welded indexed mesh (~0.6). A rebar assembly was 1.42M verts / 22.7MB. New ngeom_weld.h: weld_mesh() collapses coincident positions (bbox-relative tolerance) into a shared index buffer and recomputes each vertex normal as the area-weighted average of its incident faces, SPLIT at a crease angle (default 40deg) so curved surfaces smooth-shade + dedup while box/prism corners stay crisp. tessellate_doc welds each ROOT independently (serial + parallel paths) so group boundaries + picking never merge across solids; LINES (curve bodies) pass through untouched. Opt-out via TessParams.weld=false. reinforcing-assembly: 1.42M -> 241K verts (vpt 3.0 -> 0.51, matches Python 0.6), meshopt GLB 11.7MB -> 3.48MB (now smaller than Python's 4.18MB), bbox byte-identical (geometry unchanged). Applies to STEP and IFC native paths alike. adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_tessellate.cpp | 18 +++- src/geom/neutral/ngeom_tessellate.h | 3 + src/geom/neutral/ngeom_weld.h | 126 ++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/geom/neutral/ngeom_weld.h diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 9024199..f6dfad9 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -17,6 +17,7 @@ #include "ngeom_boolean.h" // OCC-free CSG (Manifold) for boolean roots #include "ngeom_bspline.h" +#include "ngeom_weld.h" // vertex weld + crease-angle smooth normals (per root) #include "ngeom_surfaces.h" #include "tesselator.h" // vendored libtess2 (fails soft via setjmp/longjmp -> tessTesselate // returns 0 on any sweep error; no panic to guard against, unlike the original wasm build) @@ -1922,12 +1923,23 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { // serial. Per-root parallelism is the right grain for the merge-preview generate, which streams // one root PER PLATE (thousands of roots) so every plate is its own pickable BatchMesh group. // Default (threads=1) keeps the serial path — the STEP->GLB process pool stays serial per call. + // Weld each ROOT independently (a shared index buffer + crease-angle smooth normals), then append + // — per-root keeps group boundaries + picking intact (never merges verts across solids). Skips + // LINES (curve bodies). tp.weld=false leaves the raw flat-shaded soup. + auto weld_root = [&](TessMesh &rm) { + if (tp.weld && rm.mesh_type == MeshType::TRIANGLES && !rm.indices.empty()) + weld_mesh(rm.positions, rm.indices, rm.normals); + }; unsigned want = tp.threads > 1 ? (unsigned) tp.threads : 1u; if (want <= 1 || roots.size() < 2) { for (const NgeomRoot &root : roots) { + TessMesh rm; + tessellate_one_root(root, tp, rm); + weld_root(rm); uint32_t first = (uint32_t) mesh.indices.size(); uint32_t vfirst = (uint32_t) (mesh.positions.size() / 3); - tessellate_one_root(root, tp, mesh); + mesh.mesh_type = rm.mesh_type; // single-root streaming: propagate LINES/TRIANGLES + append_mesh(mesh, rm); mesh.groups.push_back({root.id, first, (uint32_t) mesh.indices.size() - first, vfirst, (uint32_t) (mesh.positions.size() / 3) - vfirst}); } @@ -1942,8 +1954,10 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { pool.reserve(nthreads); for (unsigned t = 0; t < nthreads; ++t) pool.emplace_back([&]() { - for (size_t i = next.fetch_add(1); i < roots.size(); i = next.fetch_add(1)) + for (size_t i = next.fetch_add(1); i < roots.size(); i = next.fetch_add(1)) { tessellate_one_root(roots[i], tp1, locals[i]); + weld_root(locals[i]); + } }); for (std::thread &th : pool) th.join(); diff --git a/src/geom/neutral/ngeom_tessellate.h b/src/geom/neutral/ngeom_tessellate.h index e3995b2..dfd64c9 100644 --- a/src/geom/neutral/ngeom_tessellate.h +++ b/src/geom/neutral/ngeom_tessellate.h @@ -23,6 +23,9 @@ struct TessParams { // (serial) so callers already parallelising across roots/solids // (the STEP->GLB process pool) don't oversubscribe; a single // whole-model call (merge-preview generate) opts into all cores. + bool weld = true; // weld coincident vertices + rebuild a shared index buffer with crease-angle + // smooth normals (ngeom_weld.h) per root, turning the flat-shaded triangle + // soup into a compact indexed mesh (matches OCC density). Off => raw soup. double model_scale = 0.0; // model bbox diagonal (world units). 0 => OFF: the fixed max_angle // governs every surface (explicit-global-angle mode). >0 => ADAPTIVE: // the angular ceiling is relaxed for surfaces whose radius is small diff --git a/src/geom/neutral/ngeom_weld.h b/src/geom/neutral/ngeom_weld.h new file mode 100644 index 0000000..e536128 --- /dev/null +++ b/src/geom/neutral/ngeom_weld.h @@ -0,0 +1,126 @@ +// Weld a flat-shaded triangle-soup mesh (the tessellator emits 3 independent verts per triangle, each +// carrying that triangle's face normal — emit_tri in ngeom_tessellate.cpp) into a compact INDEXED +// mesh: coincident positions collapse to one shared vertex, and each vertex's normal becomes the +// area-weighted average of its incident faces — but split at a crease angle so hard edges stay sharp. +// +// This is what makes the native GLB match the OCC/ifcopenshell output density: a swept-disk rebar +// drops from 3 verts/tri (soup) to ~0.5 (indexed, smooth-shaded), curved surfaces shade smoothly, and +// box/prism corners keep crisp facets. Applies to any geometry type. Non-triangle (LINES) meshes and +// degenerate input pass through untouched. +#pragma once + +#include +#include +#include +#include +#include + +#include "ngeom_math.h" // Vec3 + +namespace adacpp::ngeom { + +// positions/normals: flat xyz (3 per vertex, parallel). indices: flat (3 per triangle). Rewrites all +// three in place to the welded, indexed form. crease_deg: incident faces whose normals differ by more +// than this at a shared position stay as separate vertices (hard edge). +inline void weld_mesh(std::vector &positions, std::vector &indices, + std::vector &normals, double crease_deg = 40.0) { + const size_t nt = indices.size() / 3; + const size_t nv = positions.size() / 3; + if (nt == 0 || nv == 0) + return; + const bool have_normals = normals.size() == positions.size(); + + // Relative weld tolerance from the bbox diagonal (unit-independent; coincident soup verts are + // near-identical so any small grid welds them, but this also tolerates fp noise). + Vec3 lo{1e300, 1e300, 1e300}, hi{-1e300, -1e300, -1e300}; + for (size_t v = 0; v < nv; ++v) { + double x = positions[3 * v], y = positions[3 * v + 1], z = positions[3 * v + 2]; + lo.x = std::min(lo.x, x); lo.y = std::min(lo.y, y); lo.z = std::min(lo.z, z); + hi.x = std::max(hi.x, x); hi.y = std::max(hi.y, y); hi.z = std::max(hi.z, z); + } + double diag = std::sqrt((hi.x - lo.x) * (hi.x - lo.x) + (hi.y - lo.y) * (hi.y - lo.y) + + (hi.z - lo.z) * (hi.z - lo.z)); + const double inv = 1.0 / (diag > 0 ? diag * 1e-6 : 1e-9); + auto qcoord = [&](double c) -> int64_t { return (int64_t) std::llround(c * inv); }; + + // Per-triangle face normal + area (area-weights the vertex-normal average). + std::vector fnorm(nt); + std::vector farea(nt); + for (size_t t = 0; t < nt; ++t) { + uint32_t ia = indices[3 * t], ib = indices[3 * t + 1], ic = indices[3 * t + 2]; + Vec3 a{positions[3 * ia], positions[3 * ia + 1], positions[3 * ia + 2]}; + Vec3 b{positions[3 * ib], positions[3 * ib + 1], positions[3 * ib + 2]}; + Vec3 c{positions[3 * ic], positions[3 * ic + 1], positions[3 * ic + 2]}; + Vec3 n = (b - a).cross(c - a); + double len = n.norm(); + farea[t] = 0.5 * len; + fnorm[t] = len > 1e-30 ? Vec3{n.x / len, n.y / len, n.z / len} : Vec3{0, 0, 1}; + } + + // Group soup corners by quantized position. + struct KeyHash { + size_t operator()(const std::array &k) const { + uint64_t h = 1469598103934665603ull; + for (int64_t q : k) { h ^= (uint64_t) q; h *= 1099511628211ull; } + return (size_t) h; + } + }; + std::unordered_map, std::vector, KeyHash> groups; + groups.reserve(nv); + for (size_t v = 0; v < nv; ++v) + groups[{qcoord(positions[3 * v]), qcoord(positions[3 * v + 1]), qcoord(positions[3 * v + 2])}].push_back( + (uint32_t) v); + + const double crease_cos = std::cos(crease_deg * PI / 180.0); + std::vector npos, nnrm; + std::vector remap(nv); + npos.reserve(positions.size() / 4); + nnrm.reserve(positions.size() / 4); + for (auto &[key, corners] : groups) { + // Cluster this position's incident-face normals by crease angle (greedy — a vertex's incident + // faces are locally coherent, so smooth surfaces make one cluster, a 90-degree edge makes two). + std::vector cluster_dir; // representative (first face's normal) + std::vector cluster_acc; // area-weighted accumulated normal + std::vector which(corners.size()); + for (size_t ci = 0; ci < corners.size(); ++ci) { + size_t t = corners[ci] / 3; // soup: old vertex belongs to exactly one triangle + const Vec3 &fn = fnorm[t]; + int found = -1; + for (size_t cl = 0; cl < cluster_dir.size(); ++cl) + if (cluster_dir[cl].dot(fn) >= crease_cos) { found = (int) cl; break; } + if (found < 0) { + found = (int) cluster_dir.size(); + cluster_dir.push_back(fn); + cluster_acc.push_back(Vec3{0, 0, 0}); + } + cluster_acc[found] = cluster_acc[found] + fn * farea[t]; + which[ci] = found; + } + uint32_t p0 = corners[0]; + std::vector cluster_vid(cluster_dir.size()); + for (size_t cl = 0; cl < cluster_dir.size(); ++cl) { + cluster_vid[cl] = (uint32_t) (npos.size() / 3); + npos.push_back(positions[3 * p0]); + npos.push_back(positions[3 * p0 + 1]); + npos.push_back(positions[3 * p0 + 2]); + if (have_normals) { + Vec3 n = cluster_acc[cl]; + double l = n.norm(); + Vec3 un = l > 1e-30 ? Vec3{n.x / l, n.y / l, n.z / l} : cluster_dir[cl]; + nnrm.push_back((float) un.x); + nnrm.push_back((float) un.y); + nnrm.push_back((float) un.z); + } + } + for (size_t ci = 0; ci < corners.size(); ++ci) + remap[corners[ci]] = cluster_vid[which[ci]]; + } + + for (uint32_t &i : indices) + i = remap[i]; + positions = std::move(npos); + if (have_normals) + normals = std::move(nnrm); +} + +} // namespace adacpp::ngeom From f9a3416ab1988f70d67ec85e79487470620f2fc7 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 20:10:00 +0200 Subject: [PATCH 31/65] fix: circle profile density from the angular tolerance, not a fixed 64-gon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit circle_poly baked every round profile (swept-disk rebar, circular column, pipe) as a 64-gon, independent of the tessellation tolerance — 3x finer than every other surface (~17deg vs ~5.6deg per facet). A rebar swept-disk was 13948 tris/bar vs OCC/Python's ~3900. Now the default segment count comes from a ~17deg chord angle (ceil(2pi/0.30), clamp [12,64]) so disks/cylinders match the rest of the pipeline; callers can still force an explicit n. reinforcing-assembly native vs Python: tris 474K -> 155K (Python 134K; 3.5x -> 1.16x), verts now 79K vs Python's 80K (essentially identical after welding + this), meshopt GLB 11.7MB -> 1.1MB. Coverage 25/26 unchanged, adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 564092c..039c00a 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -985,7 +985,13 @@ class IfcResolver { static std::shared_ptr make_poly_profile(std::vector poly) { return make_profile(std::move(poly)); } - static std::vector circle_poly(double r, int n = 64) { + static std::vector circle_poly(double r, int n = 0) { + // Segment count from a ~17deg chord angle — consistent with the tessellator's angular default + // (max_angle ~0.30-0.35 rad), so a swept disk / round profile isn't discretized FINER than + // every other surface. A fixed 64-gon made rebar swept-disks ~3x denser than the OCC/Python + // output (13948 vs ~3900 tris/bar). Clamp [12,64]. Callers can still force an explicit n. + if (n <= 0) + n = std::max(12, std::min(64, (int) std::ceil(TWO_PI / 0.30))); std::vector p; p.reserve(n); for (int i = 0; i < n; ++i) { From 944e6b7034b4dd55316f19488376e2de0e8604e2 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 20:21:22 +0200 Subject: [PATCH 32/65] =?UTF-8?q?feat:=20adacpp.cad.blobs=5Fto=5Fifc=20?= =?UTF-8?q?=E2=80=94=20native=20IFC=20writer=20from=20NGEOM=20blobs=20+=20?= =?UTF-8?q?metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ada-object-model counterpart of write_ifc_file_impl (STEP→IFC): decode each lazy-ShapeStore NGEOM blob to its NgeomRoot(s), re-attach the out-of-band metadata (colors rgba, per-shape world transforms, instance_paths — the blob carries geometry + root.id only), and emit the SAME IfcAdvancedBrep + IfcStyledItem + spatial tree. The enabling C++ piece for a fully native Assembly.to_ifc(writer= "native") — no STEP round-trip. Verified: advanced-brep IFC round-trips (1 solid → 1 brep/4 faces, validated). Also corrected the stale IfcNgeomStream doc comment (meta DOES carry colour + spatial path now, commits 29f50c0/aa806e9). KNOWN LIMIT: like the whole ifc_emit writer, this emits ADVANCED-BREP only — analytic NgeomRoot forms (extrusion/revolve/sweep/boolean/sphere), which the IFC READER produces for most real IFC, yield no faces and emit nothing. A full analytic round-trip needs analytic-solid IFC emit (NGEOM analytic -> IfcExtrudedAreaSolid / IfcSweptDiskSolid / IfcBooleanClippingResult / ...). adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/adacpp/cad/__init__.py | 1 + src/cad/cad_py_wrap.cpp | 94 +++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/adacpp/cad/__init__.py b/src/adacpp/cad/__init__.py index 2e65151..7cdcef2 100644 --- a/src/adacpp/cad/__init__.py +++ b/src/adacpp/cad/__init__.py @@ -30,6 +30,7 @@ stream_step_to_step = _cad.stream_step_to_step stream_ifc_to_step = _cad.stream_ifc_to_step stream_step_to_ifc = _cad.stream_step_to_ifc +blobs_to_ifc = _cad.blobs_to_ifc step_parity = _cad.step_parity glb_diff = _cad.glb_diff stream_step_to_glb_st = _cad.stream_step_to_glb_st diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index bbf76a2..646852f 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -605,8 +605,9 @@ class StepNgeomStream { // IFC GlobalId. Geometry is in FILE units — apply .unit_scale (metres per unit) on the consumer // side. Products the analytic resolver can't represent (tessellated face sets, mixed multi-solid // reps) are skipped and counted in .products_skipped so the consumer can fall back per-file. -// NOTE v1 carries no colour (IfcStyledItem unresolved) and no spatial path (IfcRelAggregates / -// IfcRelContainedInSpatialStructure not walked) — geometry/guid/name/placement only. +// meta now carries colour (IfcStyledItem -> IfcColourRgb) + the spatial-structure path +// (IfcRelContainedInSpatialStructure + IfcRelAggregates walk -> instance_paths), alongside +// geometry/guid/name/placement — so a native reader gets the full tree + colours. class IfcNgeomStream { public: explicit IfcNgeomStream(const std::string &path) : prof_("ifc_ngeom_stream") { @@ -3034,6 +3035,67 @@ static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_pat return fs; } +// Native IFC writer from NGEOM blobs (the lazy ShapeStore form) + their out-of-band metadata (colour, +// world transforms, spatial paths). The ada-object-model counterpart of write_ifc_file_impl (which +// reads a STEP file): decode each blob to its NgeomRoot(s), re-attach the passed metadata (the blob +// carries geometry + root.id only), and emit the SAME IfcAdvancedBrep + IfcStyledItem + spatial tree. +// This is what makes a fully native Assembly.to_ifc(writer="native") possible — no STEP round-trip. +static adacpp::ifc_emit::FileStats +blobs_to_ifc_impl(const std::vector &blobs, const std::vector> &colors, + const std::vector>> &transforms, + const std::vector>>> &paths, + const std::string &out_path, const std::string &schema, double unit_scale) { + using namespace adacpp::ifc_emit; + FileStats fs; + fs.unit_scale = unit_scale; + std::FILE *fp = std::fopen(out_path.c_str(), "wb"); + if (!fp) + return fs; + std::string buf; + buf.reserve(1 << 22); + auto flush = [&](bool force) { + if (buf.size() >= (4u << 20) || force) { + std::fwrite(buf.data(), 1, buf.size(), fp); + buf.clear(); + } + }; + buf += ifc_header_block(schema, unit_scale); + BrepEmitter em(100, nullptr, 2.0, 20.0); + std::vector proxies; + std::vector proxy_paths; + long sid = 0; + for (size_t i = 0; i < blobs.size(); ++i) { + adacpp::ngeom::NgeomDoc doc = + adacpp::ngeom::decode(reinterpret_cast(blobs[i].c_str()), blobs[i].size()); + for (adacpp::ngeom::NgeomRoot &root : doc.roots) { + ++sid; + ++fs.solids_in; + if (i < colors.size() && colors[i][3] >= 0.0f) { // alpha<0 sentinel => no colour + root.has_color = true; + root.cr = colors[i][0]; + root.cg = colors[i][1]; + root.cb = colors[i][2]; + root.ca = colors[i][3]; + } + if (i < transforms.size()) + root.transforms = transforms[i]; + if (i < paths.size()) + root.instance_paths = paths[i]; + size_t before = proxies.size(); + emit_solid_ifc(em, buf, root, sid, (uint64_t) sid * 1000u, proxies, &proxy_paths); + if (proxies.size() > before) + ++fs.solids_out; + } + flush(false); + } + emit_spatial_tree(buf, [&]() { return em.alloc_id(); }, proxies, proxy_paths); + buf += "ENDSEC;\nEND-ISO-10303-21;\n"; + flush(true); + std::fclose(fp); + fs.geom = em.stats(); + return fs; +} + // Append `src` to `out`, adding `offset` to every entity id (#N defs + refs) WHOSE id > `keep_below` // — so references to the shared header block (#1..#keep_below, e.g. #4/#6/#11/#12) are left intact // while the solid's local ids are shifted to its reserved global block. Skips '#' inside @@ -3978,6 +4040,34 @@ void cad_module(nb::module_ &m) { "one IfcAdvancedBrep+proxy per solid). Returns the losslessness audit dict (solids_in/out, " "faces_in/out/dropped, drop_reasons). schema: 'IFC4X3_ADD2' | 'IFC4'."); + m.def( + "blobs_to_ifc", + [](const std::vector &blobs, const std::vector> &colors, + const std::vector>> &transforms, + const std::vector>>> &paths, + const std::string &out_path, const std::string &schema, double unit_scale) -> nb::dict { + adacpp::ifc_emit::FileStats fs = + blobs_to_ifc_impl(blobs, colors, transforms, paths, out_path, schema, unit_scale); + nb::dict d; + d["solids_in"] = fs.solids_in; + d["solids_out"] = fs.solids_out; + d["unit_scale"] = fs.unit_scale; + d["faces_in"] = fs.geom.faces_in; + d["faces_out"] = fs.geom.faces_out; + d["faces_dropped"] = fs.geom.faces_dropped; + nb::dict reasons; + for (const auto &[k, v] : fs.geom.drop_reasons) + reasons[k.c_str()] = v; + d["drop_reasons"] = reasons; + return d; + }, + "blobs"_a, "colors"_a, "transforms"_a, "paths"_a, "out_path"_a, "schema"_a = "IFC4X3_ADD2", + "unit_scale"_a = 1.0, + "Native IFC writer from NGEOM blobs (the lazy ShapeStore form) + parallel per-shape metadata: " + "colors (rgba; alpha<0 => none), transforms (per-shape 4x4 world placements), paths (per-shape " + "instance_paths). Decodes each blob, re-attaches the metadata, and emits IfcAdvancedBrep + " + "IfcStyledItem + spatial tree — the fully-native Assembly.to_ifc backend. Returns the audit dict."); + // Native parallel STEP->STEP (AP242). Re-export the analytic B-rep per solid, instances baked, // round-trip-lossless. Returns the same losslessness audit dict. m.def( From e01c99da46c00c43f7399c3632c9ada942a422bf Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 20:30:30 +0200 Subject: [PATCH 33/65] =?UTF-8?q?feat:=20native=20IFC=20writer=20emits=20A?= =?UTF-8?q?NALYTIC=20solids=20(inverse=20of=20the=20IFC=20reader)=20?= =?UTF-8?q?=E2=80=94=20no=20tessellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native IFC writer only did IfcAdvancedBrep, so analytic NgeomRoots (extrusion/revolve/swept-disk/ boolean/sphere — what the IFC reader produces for most real IFC) emitted nothing. ifc_emit gains the inverse of the reader so a native round-trip keeps the CSG analytic: - profile_def: FaceSurfaceN outline -> IfcArbitraryClosedProfileDef(+WithVoids). - ExtrusionN -> IfcExtrudedAreaSolid, RevolveN -> IfcRevolvedAreaSolid, SphereN -> IfcCsgSolid(IfcSphere), SweepN(disk) -> IfcSweptDiskSolid (radius recovered from the circular profile, directrix = origin[]), BooleanN -> IfcBooleanResult (recursive operands; op 0/1/2 -> DIFFERENCE/UNION/INTERSECTION). - emit_solid() dispatches face-set -> AdvancedBrep else the analytic form + the matching RepresentationType; emit_solid_ifc + blobs_to_ifc + stream_step_to_ifc all route through it. Corpus native IFC round-trip (IfcNgeomStream -> blobs_to_ifc): 24/25 files solids_out>0 + ifcopenshell 0 validate errors (the 1 skip is pure alignment curves, no solid). reinforcing = 34 IfcSweptDiskSolid + 1 extrusion; half_space_beam = 3 extrusions + 2 IfcBooleanResult. adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 12 +-- src/cad/ifc_emit.h | 179 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 646852f..bda97a9 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -2813,10 +2813,11 @@ static void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, long sid, uint64_t guid_seed, std::vector &proxies, std::vector *proxy_paths) { using namespace adacpp::ifc_emit; - long brep = em.emit_advanced_brep(buf, root); - if (!brep) + std::string rep_type = "AdvancedBrep"; + long solid = em.emit_solid(buf, root, rep_type); // brep for face sets, else the analytic IFC solid + if (!solid) return; - // Presentation colour: style the shared brep item so it colours every (mapped) instance. The + // Presentation colour: style the shared solid item so it colours every (mapped) instance. The // NgeomRoot already carries the colour resolved by the STEP/IFC reader (has_color/cr/cg/cb/ca) — // emit it as IfcStyledItem -> IfcSurfaceStyle -> IfcColourRgb (was dropped; the viewer fell back // to grey). Matches adapy's add_colour + the Python streaming writer. @@ -2825,7 +2826,7 @@ static void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, long col = em.emit_entity(buf, "IfcColourRgb($," + R(root.cr) + "," + R(root.cg) + "," + R(root.cb) + ")"); long sh = em.emit_entity(buf, "IfcSurfaceStyleShading(#" + std::to_string(col) + "," + R(1.0f - root.ca) + ")"); long ss = em.emit_entity(buf, "IfcSurfaceStyle($,.BOTH.,(#" + std::to_string(sh) + "))"); - em.emit_entity(buf, "IfcStyledItem(#" + std::to_string(brep) + ",(#" + std::to_string(ss) + "),$)"); + em.emit_entity(buf, "IfcStyledItem(#" + std::to_string(solid) + ",(#" + std::to_string(ss) + "),$)"); } std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : ifc_str(root.id); auto record = [&](long proxy, size_t k) { @@ -2844,7 +2845,8 @@ static void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, } return nm; }; - long mrep = em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','AdvancedBrep',(#" + std::to_string(brep) + "))"); + long mrep = + em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','" + rep_type + "',(#" + std::to_string(solid) + "))"); if (root.transforms.empty()) { long pds = em.emit_entity(buf, "IfcProductDefinitionShape($,$,(#" + std::to_string(mrep) + "))"); long proxy = em.emit_entity(buf, "IfcBuildingElementProxy('" + ifc_guid(guid_seed) + "',$,'" + leaf_name(0) + diff --git a/src/cad/ifc_emit.h b/src/cad/ifc_emit.h index dcead1b..c3cc05a 100644 --- a/src/cad/ifc_emit.h +++ b/src/cad/ifc_emit.h @@ -100,19 +100,41 @@ class BrepEmitter { // Emit the root's faces as one IfcClosedShell -> IfcAdvancedBrep. Returns the IfcAdvancedBrep id, // or 0 if any face used non-emittable geometry (solid skipped wholesale, matching the Python). long emit_advanced_brep(std::string &out, const NgeomRoot &root) { + return emit_faces_brep(out, root.faces); + } + + // Emit a NgeomRoot as its native IFC solid — advanced-brep for face sets, else the ANALYTIC form + // (IfcExtrudedAreaSolid / IfcRevolvedAreaSolid / IfcSweptDiskSolid / IfcCsgSolid(sphere) / + // IfcBooleanResult) — the inverse of the IFC reader, so a native round-trip keeps the CSG analytic + // (never tessellated). Sets `rep_type` to the matching IfcShapeRepresentation.RepresentationType. + // Returns the solid item id, or 0 if unrepresentable. + long emit_solid(std::string &out, const NgeomRoot &root, std::string &rep_type) { vcache_.clear(); - std::vector face_ids; - face_ids.reserve(root.faces.size()); - for (const auto &fc : root.faces) { - long fid = face(out, *fc); - if (fid) - face_ids.push_back(fid); // a dropped face is counted in stats_ (no silent skip), - // but never sinks the whole solid — keep every good face + if (!root.faces.empty()) { + rep_type = "AdvancedBrep"; + return emit_faces_brep(out, root.faces); } - if (face_ids.empty()) - return 0; - long shell = emit(out, "IfcClosedShell(" + refs(face_ids) + ")"); - return emit(out, "IfcAdvancedBrep(#" + std::to_string(shell) + ")"); + if (root.extrusion) { + rep_type = "SweptSolid"; + return emit_extrusion(out, *root.extrusion); + } + if (root.revolve) { + rep_type = "SweptSolid"; + return emit_revolve(out, *root.revolve); + } + if (root.sphere) { + rep_type = "CSG"; + return emit_sphere(out, *root.sphere); + } + if (root.boolean) { + rep_type = "CSG"; + return emit_boolean(out, *root.boolean); + } + if (root.sweep) { + rep_type = "AdvancedSweptSolid"; + return emit_swept_disk(out, *root.sweep); + } + return 0; } private: @@ -122,6 +144,141 @@ class BrepEmitter { EmitStats stats_; std::unordered_map vcache_; // rounded-point key -> IfcVertexPoint id + // faces -> IfcClosedShell -> IfcAdvancedBrep (shared by root faces + boolean shell operands). + long emit_faces_brep(std::string &out, const std::vector> &faces) { + std::vector face_ids; + face_ids.reserve(faces.size()); + for (const auto &fc : faces) { + long fid = fc ? face(out, *fc) : 0; + if (fid) + face_ids.push_back(fid); // dropped faces counted in stats_; never sink the whole solid + } + if (face_ids.empty()) + return 0; + long shell = emit(out, "IfcClosedShell(" + refs(face_ids) + ")"); + return emit(out, "IfcAdvancedBrep(#" + std::to_string(shell) + ")"); + } + + // -- analytic solids (inverse of the IFC reader) ------------------------- + long pt2d(std::string &out, const Vec3 &p) { // profile points are LOCAL (z=0), never baked by tf_ + return emit(out, "IfcCartesianPoint((" + ifc_real(p.x) + "," + ifc_real(p.y) + "))"); + } + // Closed 2D polyline of a loop's outline (first point repeated per IFC profile rule). + long polyline2d(std::string &out, const std::vector &pts) { + std::vector ids; + ids.reserve(pts.size() + 1); + for (const Vec3 &p : pts) + ids.push_back(pt2d(out, p)); + if (pts.size() >= 2 && (pts.front() - pts.back()).norm() > 1e-9) + ids.push_back(ids.front()); + return emit(out, "IfcPolyline(" + refs(ids) + ")"); + } + long polyline3d(std::string &out, const std::vector &pts) { + std::vector ids; + ids.reserve(pts.size()); + for (const Vec3 &p : pts) + ids.push_back(pt(out, p)); + return emit(out, "IfcPolyline(" + refs(ids) + ")"); + } + // planar profile face -> IfcArbitraryClosedProfileDef (+ voids). 0 if no usable outer loop. + long profile_def(std::string &out, const FaceSurfaceN &pf) { + if (pf.bounds.empty() || !pf.bounds[0].loop) + return 0; + std::vector outer = pf.bounds[0].loop->discretize(deflection_, angular_); + if (outer.size() < 3) + return 0; + long curve = polyline2d(out, outer); + std::vector holes; + for (size_t i = 1; i < pf.bounds.size(); ++i) { + if (!pf.bounds[i].loop) + continue; + std::vector h = pf.bounds[i].loop->discretize(deflection_, angular_); + if (h.size() >= 3) + holes.push_back(polyline2d(out, h)); + } + if (!holes.empty()) + return emit(out, "IfcArbitraryProfileDefWithVoids(.AREA.,$,#" + std::to_string(curve) + "," + + refs(holes) + ")"); + return emit(out, "IfcArbitraryClosedProfileDef(.AREA.,$,#" + std::to_string(curve) + ")"); + } + long emit_extrusion(std::string &out, const ExtrusionN &ex) { + if (!ex.profile) + return 0; + long prof = profile_def(out, *ex.profile); + if (!prof) + return 0; + long pos = axis2(out, ex.frame), edir = dir(out, ex.direction); + return emit(out, "IfcExtrudedAreaSolid(#" + std::to_string(prof) + ",#" + std::to_string(pos) + ",#" + + std::to_string(edir) + "," + ifc_real(ex.depth) + ")"); + } + long emit_revolve(std::string &out, const RevolveN &rv) { + if (!rv.profile) + return 0; + long prof = profile_def(out, *rv.profile); + if (!prof) + return 0; + long pos = axis2(out, rv.frame), ax = axis1(out, rv.axis_origin, rv.axis_dir); + double angle = rv.angle > 1e-9 ? rv.angle : TWO_PI; // 0 => full revolution + return emit(out, "IfcRevolvedAreaSolid(#" + std::to_string(prof) + ",#" + std::to_string(pos) + ",#" + + std::to_string(ax) + "," + ifc_real(angle) + ")"); + } + long emit_sphere(std::string &out, const SphereN &sp) { + long pos = axis2(out, sp.frame); + long prim = emit(out, "IfcSphere(#" + std::to_string(pos) + "," + ifc_real(sp.radius) + ")"); + return emit(out, "IfcCsgSolid(#" + std::to_string(prim) + ")"); + } + // A disk/annulus swept along a directrix -> IfcSweptDiskSolid(Directrix, Radius, InnerRadius). The + // radius is recovered from the circular profile; origin[] (frame-applied) is the directrix. + long emit_swept_disk(std::string &out, const SweepN &sw) { + if (sw.origin.size() < 2 || !sw.profile || sw.profile->bounds.empty() || !sw.profile->bounds[0].loop) + return 0; + std::vector ring = sw.profile->bounds[0].loop->discretize(deflection_, angular_); + if (ring.size() < 3) + return 0; + Vec3 c{0, 0, 0}; + for (const Vec3 &p : ring) + c = c + p; + c = c * (1.0 / (double) ring.size()); + double radius = (ring[0] - c).norm(); + if (radius <= 1e-9) + return 0; + double inner = 0.0; + if (sw.profile->bounds.size() > 1 && sw.profile->bounds[1].loop) { + std::vector ih = sw.profile->bounds[1].loop->discretize(deflection_, angular_); + if (ih.size() >= 3) + inner = (ih[0] - c).norm(); + } + std::vector dpts; + dpts.reserve(sw.origin.size()); + for (const Vec3 &o : sw.origin) + dpts.push_back(sw.frame.to_world(o.x, o.y, o.z)); + long directrix = polyline3d(out, dpts); + std::string inner_s = inner > 1e-9 ? ("," + ifc_real(inner)) : ",$"; + return emit(out, "IfcSweptDiskSolid(#" + std::to_string(directrix) + "," + ifc_real(radius) + inner_s + + ",$,$)"); + } + long emit_solid_operand(std::string &out, const SolidItemN &it) { + if (it.extrusion) + return emit_extrusion(out, *it.extrusion); + if (it.revolve) + return emit_revolve(out, *it.revolve); + if (it.sweep) + return emit_swept_disk(out, *it.sweep); + if (it.boolean) + return emit_boolean(out, *it.boolean); + if (!it.faces.empty()) + return emit_faces_brep(out, it.faces); + return 0; + } + long emit_boolean(std::string &out, const BooleanN &b) { + long a = emit_solid_operand(out, b.a), bb = emit_solid_operand(out, b.b); + if (!a || !bb) + return 0; + const char *op = b.op == 1 ? ".UNION." : (b.op == 2 ? ".INTERSECTION." : ".DIFFERENCE."); + return emit(out, std::string("IfcBooleanResult(") + op + ",#" + std::to_string(a) + ",#" + + std::to_string(bb) + ")"); + } + long emit(std::string &out, const std::string &body) { ++nid_; out += "#"; From 0e577d3e06f1680bf42cdafe69fbd852621ea990 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 21:10:02 +0200 Subject: [PATCH 34/65] feat: IFC->STEP emits analytic swept-disk + sphere (AP242 SWEPT_DISK_SOLID / CSG sphere) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit step_emit gains emit_swept_disk (SweepN -> SWEPT_DISK_SOLID, radius from the circular profile, directrix = origin[] baked to world) and emit_sphere (SphereN -> CSG_SOLID over SPHERE); emit_solid_item + the root dispatch (emit_solid_step) route sweep/sphere, and write_ifc_to_step_impl's skip guard now lets root.sweep/root.sphere through (was silently dropping them). Closes the analytic gap that mirrored ifc_emit's: reinforcing IFC->STEP now emits 34 SWEPT_DISK_SOLID + 1 EXTRUDED_AREA_SOLID (was 34 skipped); half_space_beam 3 extrusion + 2 BOOLEAN_RESULT. IFC->STEP corpus 24/24 solids_out>0. adacpp green (65). NOTE: the emitted STEP is valid AP242 (parses in OCC, status RetDone) but OCC does NOT *transfer* the analytic swept/extruded solids (0 roots) — only ADVANCED_BREP_SHAPE_REPRESENTATION transfers in OCC. This pre-dates the change (the existing extrusion emit has the same limitation); the analytic forms are correct per spec and read by other STEP tools / a native reader extension. Separate reader-side concern. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 8 +++++++- src/cad/step_emit.h | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index bda97a9..ff17b93 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -3386,6 +3386,12 @@ static bool emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string } else if (root.revolve) { // non-rigid revolves are dropped to OCC by the reader -> rigid here solid = em.emit_revolve(buf, *root.revolve); rep_kw = "SHAPE_REPRESENTATION"; + } else if (root.sweep) { // disk/annulus swept along a directrix -> SWEPT_DISK_SOLID + solid = em.emit_swept_disk(buf, *root.sweep); + rep_kw = "SHAPE_REPRESENTATION"; + } else if (root.sphere) { // CSG sphere primitive + solid = em.emit_sphere(buf, *root.sphere); + rep_kw = "SHAPE_REPRESENTATION"; } else if (root.boolean) { // CSG tree (BOOLEAN_RESULT), preserved not evaluated solid = em.emit_boolean(buf, *root.boolean); rep_kw = "SHAPE_REPRESENTATION"; @@ -3771,7 +3777,7 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ }; for (long pid : roots) { NgeomRoot root = r.resolve_product(pid); - if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean) { + if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean && !root.sweep && !root.sphere) { ++fs.products_skipped; // a product whose geometry the analytic reader couldn't represent continue; } diff --git a/src/cad/step_emit.h b/src/cad/step_emit.h index 41a1ab7..a53215a 100644 --- a/src/cad/step_emit.h +++ b/src/cad/step_emit.h @@ -118,12 +118,53 @@ class StepBrepEmitter { std::to_string(ax) + "," + ifc_real(rv.angle) + ")"); } + // A disk/annulus swept along a directrix -> SWEPT_DISK_SOLID (radius recovered from the circular + // profile; directrix = origin[] baked to world via frame o tf). Matches the ng:: swept-disk. + long emit_swept_disk(std::string &out, const SweepN &sw) { + if (sw.origin.size() < 2 || !sw.profile || sw.profile->bounds.empty() || !sw.profile->bounds[0].loop) + return 0; + const LoopN &lp = *sw.profile->bounds[0].loop; + std::vector ring = lp.is_poly ? lp.polygon : lp.discretize(deflection_, angular_); + if (ring.size() < 3) + return 0; + Vec3 c{0, 0, 0}; + for (const Vec3 &p : ring) + c = c + p; + c = c * (1.0 / (double) ring.size()); + double radius = (ring[0] - c).norm(); + if (radius <= 1e-9) + return 0; + double inner = 0.0; + if (sw.profile->bounds.size() > 1 && sw.profile->bounds[1].loop) { + const LoopN &ih = *sw.profile->bounds[1].loop; + std::vector il = ih.is_poly ? ih.polygon : ih.discretize(deflection_, angular_); + if (il.size() >= 3) + inner = (il[0] - c).norm(); + } + std::vector dids; + dids.reserve(sw.origin.size()); + for (const Vec3 &o : sw.origin) + dids.push_back(pt_raw(out, tp(sw.frame.to_world(o.x, o.y, o.z)))); + long directrix = emit(out, "POLYLINE(''," + refs(dids) + ")"); + std::string inner_s = inner > 1e-9 ? ifc_real(inner) : "$"; + return emit(out, "SWEPT_DISK_SOLID('',#" + std::to_string(directrix) + "," + ifc_real(radius) + "," + inner_s + + ",$,$)"); + } + // A sphere -> CSG_SOLID over a SPHERE primitive (centre baked to world). + long emit_sphere(std::string &out, const SphereN &sp) { + long ctr = pt_raw(out, tp(sp.frame.o)); + long sph = emit(out, "SPHERE(''," + ifc_real(sp.radius) + ",#" + std::to_string(ctr) + ")"); + return emit(out, "CSG_SOLID('',#" + std::to_string(sph) + ")"); + } + // Emit a boolean-operand solid -> its STEP id (0 if unrepresentable). Recursive for nested booleans. long emit_solid_item(std::string &out, const SolidItemN &it) { if (it.extrusion) return emit_extrusion(out, *it.extrusion); if (it.revolve) return emit_revolve(out, *it.revolve); + if (it.sweep) + return emit_swept_disk(out, *it.sweep); if (it.boolean) return emit_boolean(out, *it.boolean); return 0; // brep-faces operands not emitted here (rare in CSG) -> caller drops to OCC From 0717715862fe3a81de8a8f09e06b0aa93a7e8a81 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 21:19:03 +0200 Subject: [PATCH 35/65] feat: IFC writer uses generic IfcSpatialZone hierarchy (not IfcBuilding/Storey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per request — a domain-neutral spatial hierarchy so any STEP/IFC assembly round-trips without building semantics. ifc_header_block now emits IfcProject -> a root IfcSpatialZone (#12) via IfcRelAggregates (#13); emit_spatial_tree maps each assembly-path level to a nested IfcSpatialZone (RelAggregates) and contains the leaf elements in their deepest zone via IfcRelContainedInSpatialStructure (IfcSpatialZone is a valid IfcSpatialElement RelatingStructure). Dropped IfcSite/IfcBuilding/IfcBuildingStorey; header block shrank #1..#17 -> #1..#13 (K updated in the parallel writer + renumber threshold; STOREY -> ROOT zone #12). Applies to stream_step_to_ifc / write_ifc_file_impl / blobs_to_ifc alike. Verified ifcopenshell.validate 0 errors: STEP->IFC (1 zone), native IFC->IFC reinforcing (5 nested zones, the assembly tree; 0 Building/Storey). adacpp suites green (65); native-ifc-emit suite green (43). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 75 ++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index ff17b93..0f932c8 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -2887,39 +2887,41 @@ static void emit_spatial_tree(std::string &out, const std::function &nex const std::vector &paths) { using adacpp::ifc_emit::ifc_guid; using adacpp::ifc_emit::ifc_str; - std::map, long> asm_id; // rep-id prefix -> IfcElementAssembly id - std::map> children; // parent entity id -> child entity ids - const long STOREY = 14; - std::vector storey_children; - uint64_t aguid = 0xE0000000ull; // assembly/rel GUID namespace (disjoint from header 0xF.. + proxies) + // Generic, domain-neutral spatial hierarchy: each assembly-path level is an IfcSpatialZone (not a + // building-specific IfcBuildingStorey / element-grouping IfcElementAssembly). Zones nest under the + // root zone (#12) via IfcRelAggregates; the leaf elements are contained in their deepest zone via + // IfcRelContainedInSpatialStructure (IfcSpatialZone is a valid IfcSpatialElement RelatingStructure). + const long ROOT = 12; // the header's root IfcSpatialZone (under IfcProject) + std::map, long> zone_id; // rep-id prefix -> IfcSpatialZone id + std::map> agg_children; // parent zone -> child zones (IfcRelAggregates) + std::map> contained; // zone -> contained leaf elements (IfcRelContained…) + uint64_t aguid = 0xE0000000ull; // zone/rel GUID namespace (disjoint from header 0xF.. + proxies) for (size_t i = 0; i < proxies.size(); ++i) { const IfcPath &path = (i < paths.size()) ? paths[i] : IfcPath{}; - // Intermediate assembly levels = path[0 .. n-2]; path.back() is the solid's own (leaf) product. - long parent = STOREY; - bool parent_is_storey = true; + // Intermediate spatial levels = path[0 .. n-2]; path.back() is the solid's own (leaf) product. + long parent_zone = ROOT; std::vector prefix; for (size_t d = 0; d + 1 < path.size(); ++d) { prefix.push_back(path[d].first); - auto it = asm_id.find(prefix); - long aid; - if (it == asm_id.end()) { - aid = next_id(); - asm_id[prefix] = aid; - std::string an = - path[d].second.empty() ? ("asm_" + std::to_string(path[d].first)) : ifc_str(path[d].second); - out += "#" + std::to_string(aid) + "=IFCELEMENTASSEMBLY('" + ifc_guid(aguid++) + "',$,'" + an + - "',$,$,#11,$,$,.NOTDEFINED.,.NOTDEFINED.);\n"; - (parent_is_storey ? storey_children : children[parent]).push_back(aid); + auto it = zone_id.find(prefix); + long zid; + if (it == zone_id.end()) { + zid = next_id(); + zone_id[prefix] = zid; + std::string zn = + path[d].second.empty() ? ("zone_" + std::to_string(path[d].first)) : ifc_str(path[d].second); + out += "#" + std::to_string(zid) + "=IFCSPATIALZONE('" + ifc_guid(aguid++) + "',$,'" + zn + + "',$,$,#11,$,$,.NOTDEFINED.);\n"; + agg_children[parent_zone].push_back(zid); } else { - aid = it->second; + zid = it->second; } - parent = aid; - parent_is_storey = false; + parent_zone = zid; } - (parent_is_storey ? storey_children : children[parent]).push_back(proxies[i]); + contained[parent_zone].push_back(proxies[i]); } - for (const auto &[pid, kids] : children) { + for (const auto &[pid, kids] : agg_children) { std::string refs = "("; for (size_t j = 0; j < kids.size(); ++j) refs += (j ? ",#" : "#") + std::to_string(kids[j]); @@ -2927,13 +2929,13 @@ static void emit_spatial_tree(std::string &out, const std::function &nex out += "#" + std::to_string(next_id()) + "=IFCRELAGGREGATES('" + ifc_guid(aguid++) + "',$,$,$,#" + std::to_string(pid) + "," + refs + ");\n"; } - if (!storey_children.empty()) { + for (const auto &[zid, kids] : contained) { std::string refs = "("; - for (size_t j = 0; j < storey_children.size(); ++j) - refs += (j ? ",#" : "#") + std::to_string(storey_children[j]); + for (size_t j = 0; j < kids.size(); ++j) + refs += (j ? ",#" : "#") + std::to_string(kids[j]); refs += ")"; out += "#" + std::to_string(next_id()) + "=IFCRELCONTAINEDINSPATIALSTRUCTURE('" + ifc_guid(aguid++) + - "',$,$,$," + refs + ",#" + std::to_string(STOREY) + ");\n"; + "',$,$,$," + refs + ",#" + std::to_string(zid) + ");\n"; } } @@ -2965,21 +2967,18 @@ static std::string ifc_header_block(const std::string &schema, double unit_scale std::string b; b += "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n"; b += "FILE_NAME('','',(''),(''),'adacpp','','');\nFILE_SCHEMA(('" + schema + "'));\nENDSEC;\nDATA;\n"; - // adapy's default spatial hierarchy: IfcProject -> IfcSite -> IfcBuilding -> IfcBuildingStorey, - // chained by IfcRelAggregates. Elements/assemblies are contained in the storey (#14). Reserved - // header ids #1..#17 (K) — keep in sync with the parallel writer's K + renumber threshold. + // Generic, domain-neutral spatial hierarchy: IfcProject -> a root IfcSpatialZone (#12), aggregated + // by IfcRelAggregates (#13). Assembly-path levels become nested IfcSpatialZones under #12 and the + // leaf elements are contained in their deepest zone (emit_spatial_tree). No building semantics. + // Reserved header ids #1..#13 (K) — keep in sync with the parallel writer's K + renumber threshold. b += "#1=IFCCARTESIANPOINT((0.,0.,0.));\n#2=IFCDIRECTION((0.,0.,1.));\n#3=IFCDIRECTION((1.,0.,0.));\n" "#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);\n#5=IFCDIRECTION((1.,0.));\n" "#6=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#4,#5);\n"; b += ifc_length_unit_line(unit_scale); - b += "#9=IFCPROJECT('" + ifc_guid(0xF0000001ull) + "',$,'adacpp STEP->IFC',$,$,$,$,(#6),#8);\n"; + b += "#9=IFCPROJECT('" + ifc_guid(0xF0000001ull) + "',$,'adacpp model',$,$,$,$,(#6),#8);\n"; b += "#10=IFCAXIS2PLACEMENT3D(#1,$,$);\n#11=IFCLOCALPLACEMENT($,#10);\n"; - b += "#12=IFCSITE('" + ifc_guid(0xF0000002ull) + "',$,'Site',$,$,#11,$,$,.ELEMENT.,$,$,$,$,$);\n"; - b += "#13=IFCBUILDING('" + ifc_guid(0xF0000003ull) + "',$,'Building',$,$,#11,$,$,.ELEMENT.,$,$,$);\n"; - b += "#14=IFCBUILDINGSTOREY('" + ifc_guid(0xF0000004ull) + "',$,'Storey',$,$,#11,$,$,.ELEMENT.,$);\n"; - b += "#15=IFCRELAGGREGATES('" + ifc_guid(0xF0000005ull) + "',$,$,$,#9,(#12));\n"; - b += "#16=IFCRELAGGREGATES('" + ifc_guid(0xF0000006ull) + "',$,$,$,#12,(#13));\n"; - b += "#17=IFCRELAGGREGATES('" + ifc_guid(0xF0000007ull) + "',$,$,$,#13,(#14));\n"; + b += "#12=IFCSPATIALZONE('" + ifc_guid(0xF0000002ull) + "',$,'Model',$,$,#11,$,$,.NOTDEFINED.);\n"; + b += "#13=IFCRELAGGREGATES('" + ifc_guid(0xF0000005ull) + "',$,$,$,#9,(#12));\n"; return b; } @@ -3178,7 +3177,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin roots[i] = cost[i].second; } prof.phase("lpt_order"); - const long K = 17; // ids #1..#17 are the shared header block (Project/Site/Building/Storey + rels) + const long K = 13; // ids #1..#13 are the shared header block (Project + root IfcSpatialZone + rel) // Robust global id allocation: each solid is emitted with LOCAL ids (1..n), then a contiguous // block of n ids is reserved atomically and the solid's text is renumbered by the block base. // No STRIDE guessing, no overflow, compact ids — correct regardless of per-face entity counts. From d9cd25d81695b434a9ec01b0e35ff973008ae1e2 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 21:26:10 +0200 Subject: [PATCH 36/65] feat: IFC->STEP emits a NEXT_ASSEMBLY_USAGE_OCCURRENCE assembly tree (hierarchy round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The STEP writer baked flat products (no assembly structure), so IFC->STEP dropped the hierarchy. Now emit_solid_step returns its leaf PRODUCT_DEFINITION id, and a new emit_step_assembly_tree hangs a NAUO tree off the per-leaf (product_definition, instance_path): each intermediate path level becomes an assembly PRODUCT_DEFINITION linked to its children by NEXT_ASSEMBLY_USAGE_OCCURRENCE — the STEP counterpart of the IFC emit_spatial_tree, so the tree survives IFC->STEP. Grouping nodes carry no own shape; placements stay baked into the leaf geometry. Verified: reinforcing IFC->STEP now emits 138 NAUO + 34 SWEPT_DISK_SOLID + 1 EXTRUDED_AREA_SOLID (was flat). STEP->IFC already round-trips a STEP assembly (as1-oc-214: 13 NAUO -> 5 IfcSpatialZones). adacpp green (65); native-ifc-emit green (43). REMAINING for the full native IFC->STEP->IFC round-trip: the native STEP READER (step_reader.h) does not yet parse SWEPT_DISK_SOLID (nor revolved/CSG) back into ng::, so only brep/extrusion products survive a read-back today — a reader-side follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 60 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 0f932c8..9313121 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -3369,7 +3369,9 @@ static std::string step_header_block(double unit_scale) { // Emit one solid instance (geometry baked by `em`'s transform) as a self-contained AP242 part: // MANIFOLD_SOLID_BREP / EXTRUDED_AREA_SOLID -> (ADVANCED_BREP_)SHAPE_REPRESENTATION (placement #13, // context #9) -> PRODUCT chain -> SHAPE_DEFINITION_REPRESENTATION. Returns true if a solid was emitted. -static bool emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, +// Returns the leaf PRODUCT_DEFINITION id (0 on failure) so the caller can hang a NEXT_ASSEMBLY_USAGE_ +// OCCURRENCE assembly tree off it; treat nonzero as success. +static long emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, const adacpp::ngeom::NgeomRoot &root, long sid) { std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : adacpp::ifc_emit::ifc_str(root.id); long solid = 0; @@ -3398,14 +3400,56 @@ static bool emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string solid = em.emit_manifold_brep(buf, root, nm); } if (!solid) - return false; + return 0; long rep = em.emit_entity(buf, std::string(rep_kw) + "('" + nm + "',(#13,#" + std::to_string(solid) + "),#9)"); long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); long pds = em.emit_entity(buf, "PRODUCT_DEFINITION_SHAPE('','',#" + std::to_string(pd) + ")"); em.emit_entity(buf, "SHAPE_DEFINITION_REPRESENTATION(#" + std::to_string(pds) + ",#" + std::to_string(rep) + ")"); - return true; + return pd; +} + +// Emit a NEXT_ASSEMBLY_USAGE_OCCURRENCE assembly tree from per-leaf (product_definition, path): each +// intermediate path level becomes an assembly PRODUCT_DEFINITION, linked to its children (assemblies or +// leaf solids) by a NAUO — the STEP counterpart of emit_spatial_tree, so the IFC/STEP hierarchy round- +// trips. Assembly nodes carry no own shape (grouping only); placements stay baked into the leaf geometry. +using IfcPath2 = std::vector>; +static void emit_step_assembly_tree(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, + const std::vector &leaf_pds, const std::vector &paths) { + using adacpp::ifc_emit::ifc_str; + std::map, long> asm_pd; // rep-id prefix -> assembly PRODUCT_DEFINITION id + auto asm_node = [&](const std::vector &prefix, const std::string &name) -> long { + auto it = asm_pd.find(prefix); + if (it != asm_pd.end()) + return it->second; + std::string nm = name.empty() ? ("asm_" + std::to_string(prefix.back())) : ifc_str(name); + long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); + long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); + long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); + asm_pd[prefix] = pd; + return pd; + }; + auto nauo = [&](long parent_pd, long child_pd, const std::string &nm) { + em.emit_entity(buf, "NEXT_ASSEMBLY_USAGE_OCCURRENCE('','" + nm + "','',#" + std::to_string(parent_pd) + ",#" + + std::to_string(child_pd) + ",$)"); + }; + for (size_t i = 0; i < leaf_pds.size(); ++i) { + if (!leaf_pds[i]) + continue; + const IfcPath2 &path = (i < paths.size()) ? paths[i] : IfcPath2{}; + long parent_pd = 0; + std::vector prefix; + for (size_t d = 0; d + 1 < path.size(); ++d) { + prefix.push_back(path[d].first); + long apd = asm_node(prefix, path[d].second); + if (parent_pd) + nauo(parent_pd, apd, path[d].second); + parent_pd = apd; + } + if (parent_pd) // hang the leaf solid under its deepest assembly (top-level leaves stay roots) + nauo(parent_pd, leaf_pds[i], path.empty() ? "" : path.back().second); + } } // Native parallel STEP->STEP (AP242). Mirrors the parallel IFC writer: shared StreamIndex, per-worker @@ -3774,6 +3818,8 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ buf.clear(); } }; + std::vector leaf_pds; // per emitted solid: its PRODUCT_DEFINITION id (for the NAUO tree) + std::vector leaf_paths; // parallel: the solid's assembly path for (long pid : roots) { NgeomRoot root = r.resolve_product(pid); if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean && !root.sweep && !root.sphere) { @@ -3804,9 +3850,12 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ tfp = tf; } StepBrepEmitter em(nid, tfp, deflection, angular_deg); - if (emit_solid_step(em, buf, root, pid)) { + long pd = emit_solid_step(em, buf, root, pid); + if (pd) { nid = em.current_id(); any = true; + leaf_pds.push_back(pd); + leaf_paths.push_back(k < root.instance_paths.size() ? root.instance_paths[k] : IfcPath2{}); const auto &s = em.stats(); fs.geom.faces_in += s.faces_in; fs.geom.faces_out += s.faces_out; @@ -3823,6 +3872,9 @@ static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ flush(false); } prof.phase("resolve+emit"); + // NAUO assembly tree over all emitted leaves (STEP counterpart of the IFC spatial tree). + StepBrepEmitter emtree(nid, nullptr, deflection, angular_deg); + emit_step_assembly_tree(emtree, buf, leaf_pds, leaf_paths); const char *foot = "ENDSEC;\nEND-ISO-10303-21;\n"; buf += foot; flush(true); From f9c7fd3f5de96258d2dfb4fe6d569fa9ef51e32d Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 21:59:11 +0200 Subject: [PATCH 37/65] feat: native STEP reader reads SWEPT_DISK_SOLID -> ng::SweepN (IFC->STEP->IFC geometry round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The STEP reader resolved brep/extrusion/revolve/boolean but not swept solids, so an IFC->STEP-emitted rebar (SWEPT_DISK_SOLID) was dropped on read-back. New shared header ngeom_sweep.h factors the swept-disk builder (parallel-transport frames + circular profile) out of the IFC reader so BOTH readers resolve identical geometry; step_reader gains build_swept_disk (directrix POLYLINE + radius/inner) and routes SWEPT_DISK_SOLID in resolve_root, the root-enumeration gates, and the boolean-operand dispatch. Verified: reinforcing IFC->STEP->read-back now renders 35/35 solids (155K tris, was 1); full IFC->STEP->IFC recovers 35 proxies + 34 IfcSweptDiskSolid (analytic preserved), ifcopenshell 0 validate errors. adacpp suites green (65). REMAINING (hierarchy): the IFC->STEP writer emits a NAUO product tree, but the STEP reader builds instance_paths from the CDSR/SRR *placement* graph — so the NAUO tree isn't read back (zones collapse to 1 on IFC->STEP->IFC). Aligning them (reader reads NAUO, or writer emits CDSR occurrences) is the last hierarchy step; geometry + STEP->IFC hierarchy already round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cadit/step/step_reader.h | 29 +++++++++- src/geom/neutral/ngeom_sweep.h | 103 +++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/geom/neutral/ngeom_sweep.h diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index cd7bf06..64de197 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -38,6 +38,7 @@ #endif #include "ngeom_bspline.h" // BSplineCurve / BSplineSurface / expand_knots +#include "ngeom_sweep.h" // make_swept_disk (shared with the IFC reader) #include "ngeom_topology.h" // pulls ngeom_curves.h / ngeom_surfaces.h / ngeom_math.h #include "step_part21.h" @@ -587,7 +588,8 @@ class StreamIndex { ++p; std::string_view t(t0, p - t0); if (t == "MANIFOLD_SOLID_BREP" || t == "SHELL_BASED_SURFACE_MODEL" || t == "BREP_WITH_VOIDS" || - t == "EXTRUDED_AREA_SOLID" || t == "REVOLVED_AREA_SOLID" || t == "BOOLEAN_RESULT") + t == "EXTRUDED_AREA_SOLID" || t == "REVOLVED_AREA_SOLID" || t == "SWEPT_DISK_SOLID" || + t == "BOOLEAN_RESULT") lists.roots.push_back(id); else if (t == "STYLED_ITEM") lists.styled.push_back(id); @@ -722,6 +724,9 @@ class Resolver { } else if (in->type == "REVOLVED_AREA_SOLID") { root.revolve = build_revolve(in); // ('', #profile, #position, #axis1, angle) in = nullptr; + } else if (in->type == "SWEPT_DISK_SOLID") { + root.sweep = build_swept_disk(in); // ('', #directrix, radius, inner, start, end) + in = nullptr; } else if (in->type == "BOOLEAN_RESULT") { root.boolean = build_boolean(in); // ('', operator, #first, #second) in = nullptr; @@ -840,7 +845,7 @@ class Resolver { } std::string_view t = in->type; if (t == "MANIFOLD_SOLID_BREP" || t == "SHELL_BASED_SURFACE_MODEL" || t == "EXTRUDED_AREA_SOLID" || - t == "REVOLVED_AREA_SOLID") + t == "REVOLVED_AREA_SOLID" || t == "SWEPT_DISK_SOLID") tl.roots.push_back(id); else if (t == "STYLED_ITEM") tl.styled.push_back(id); @@ -1032,6 +1037,24 @@ class Resolver { return ex; } + // SWEPT_DISK_SOLID('', #directrix, radius, inner_radius, start, end) -> ng::SweepN. Directrix is a + // POLYLINE of 3D points; radius/inner give the disk/annulus. The inverse of emit_swept_disk, so an + // IFC->STEP->IFC rebar recovers the same swept disk (shared make_swept_disk with the IFC reader). + std::shared_ptr build_swept_disk(const Instance *in) { + if (!in || in->args.size() < 3 || !in->args[1].is_ref()) + return nullptr; + const Instance *dc = inst(in->args[1].i); + if (!dc || dc->type != "POLYLINE" || dc->args.size() < 2 || dc->args[1].kind != Kind::List) + return nullptr; + std::vector pts; + for (const Value &pr : dc->args[1].items) + if (pr.is_ref()) + pts.push_back(point(pr.i)); // 3D directrix point + double radius = in->args[2].as_double(); + double inner = in->args.size() > 3 ? in->args[3].as_double() : 0.0; // $ => as_double() 0 + return ng::make_swept_disk(pts, radius, inner); + } + // REVOLVED_AREA_SOLID('', #profile, #position, #axis1, angle) -> ng::RevolveN (null if no profile). std::shared_ptr build_revolve(const Instance *in) { if (!in || in->args.size() < 5) @@ -1060,6 +1083,8 @@ class Resolver { it.extrusion = build_extrusion(in); else if (in->type == "REVOLVED_AREA_SOLID") it.revolve = build_revolve(in); + else if (in->type == "SWEPT_DISK_SOLID") + it.sweep = build_swept_disk(in); else if (in->type == "BOOLEAN_RESULT") it.boolean = build_boolean(in); else if (in->type == "MANIFOLD_SOLID_BREP" && in->args.size() > 1 && in->args[1].is_ref()) diff --git a/src/geom/neutral/ngeom_sweep.h b/src/geom/neutral/ngeom_sweep.h new file mode 100644 index 0000000..6ff9b60 --- /dev/null +++ b/src/geom/neutral/ngeom_sweep.h @@ -0,0 +1,103 @@ +// Shared swept-disk builder: a disk/annulus swept along a directrix polyline -> ng::SweepN with +// rotation-minimising (parallel-transport) per-station frames, so the circular profile doesn't twist. +// Used by BOTH the IFC reader (IfcSweptDiskSolid) and the STEP reader (SWEPT_DISK_SOLID) so the two +// resolve identical geometry — and so an IFC->STEP->IFC round-trip of a rebar recovers the same sweep. +#pragma once + +#include +#include +#include +#include + +#include "ngeom_math.h" // Vec3, Frame +#include "ngeom_surfaces.h" // PlaneSurface +#include "ngeom_topology.h" // FaceSurfaceN, LoopN, FaceBoundN, SweepN + +namespace adacpp::ngeom { + +// A regular n-gon of radius r in the z=0 plane. n<=0 => a segment count from a ~17deg chord angle, +// consistent with the tessellator's angular default (so a disk isn't finer than every other surface). +inline std::vector sweep_circle_poly(double r, int n = 0) { + if (n <= 0) + n = std::max(12, std::min(64, (int) std::ceil(TWO_PI / 0.30))); + std::vector p; + p.reserve(n); + for (int i = 0; i < n; ++i) { + double a = TWO_PI * i / n; + p.push_back({r * std::cos(a), r * std::sin(a), 0}); + } + return p; +} + +// A planar (z=0) profile face from an outer polygon + optional hole polygons (holes reversed). +inline std::shared_ptr sweep_make_profile(std::vector outer, + std::vector> holes = {}) { + if (outer.size() < 3) + return nullptr; + auto prof = std::make_shared(); + prof->surface = std::make_shared(Frame{}); + prof->same_sense = true; + auto add = [&](std::vector poly) { + auto lp = std::make_shared(); + lp->is_poly = true; + lp->polygon = std::move(poly); + FaceBoundN fb; + fb.loop = lp; + fb.orientation = true; + prof->bounds.push_back(fb); + }; + add(std::move(outer)); + for (auto &h : holes) + if (h.size() >= 3) { + std::reverse(h.begin(), h.end()); + add(std::move(h)); + } + return prof; +} + +// Disk/annulus of `radius` (+ optional `inner`) swept along `pts` -> SweepN with parallel-transport +// (rotation-minimising) frames. Mirrors adapy's _sweep_frames. Null on a degenerate directrix. +inline std::shared_ptr make_swept_disk(const std::vector &pts, double radius, double inner) { + if (pts.size() < 2 || radius <= 0) + return nullptr; + int n = (int) pts.size(); + std::vector tan(n); + for (int i = 0; i < n; ++i) { + Vec3 tv = (i == 0) ? pts[1] - pts[0] : (i == n - 1) ? pts[n - 1] - pts[n - 2] : pts[i + 1] - pts[i - 1]; + tan[i] = tv.norm() > 1e-12 ? tv.normalized() : Vec3{0, 0, 1}; + } + auto sw = std::make_shared(); + sw->frame = Frame{}; // origin/dir_x/dir_y are already world + sw->origin.resize(n); + sw->dir_x.resize(n); + sw->dir_y.resize(n); + Vec3 up0 = std::abs(tan[0].z) < 0.9 ? Vec3{0, 0, 1} : Vec3{1, 0, 0}; + Vec3 dx = (up0 - tan[0] * tan[0].dot(up0)).normalized(); + for (int i = 0; i < n; ++i) { + if (i > 0) { // parallel-transport dx across the tangent turn (Rodrigues) + Vec3 axis = tan[i - 1].cross(tan[i]); + double s = axis.norm(), cth = tan[i - 1].dot(tan[i]); + if (s > 1e-9) { + axis = axis * (1.0 / s); + double ang = std::atan2(s, cth); + dx = dx * std::cos(ang) + axis.cross(dx) * std::sin(ang) + axis * (axis.dot(dx) * (1 - std::cos(ang))); + } + } + Vec3 ortho = dx - tan[i] * tan[i].dot(dx); + if (ortho.norm() < 1e-9) { // dx drifted parallel to the tangent -> reseed perpendicular + Vec3 up = std::abs(tan[i].z) < 0.9 ? Vec3{0, 0, 1} : Vec3{1, 0, 0}; + ortho = up - tan[i] * tan[i].dot(up); + } + dx = ortho.normalized(); + sw->origin[i] = pts[i]; + sw->dir_x[i] = dx; + sw->dir_y[i] = tan[i].cross(dx); + } + std::vector> holes; + if (inner > 1e-9) + holes.push_back(sweep_circle_poly(inner)); + sw->profile = sweep_make_profile(sweep_circle_poly(radius), holes); + return sw->profile ? sw : nullptr; +} + +} // namespace adacpp::ngeom From b90d338e19a9223a3d19609174d13537718e6c97 Mon Sep 17 00:00:00 2001 From: krande Date: Thu, 9 Jul 2026 22:24:26 +0200 Subject: [PATCH 38/65] STEP reader: read NAUO product tree into instance_paths A STEP file written with baked per-leaf geometry (e.g. our own IFC->STEP emit) carries no CDSR/SRR placement graph, so world_matrices() gave every solid a flat single-level path and the spatial hierarchy collapsed on re-import. Recover it from the PRODUCT-level NEXT_ASSEMBLY_USAGE_OCCURRENCE chain: rep -> leaf PD (via SDR), child PD -> parent PD (via NAUO), walk to the assembly root and override the solid's path with the full ancestor chain (leaf level kept as the solid's own (geom_rep, product_name) so the IFC writer zones identically to the CDSR-derived paths). Closes the IFC->STEP->IFC hierarchy round-trip: as1-oc-214 STEP->IFC (7 IfcSpatialZone) -> STEP (47 NAUO, no CDSR) -> IFC now recovers the assembly tree (6 aggregates) instead of collapsing to one zone; ifcopenshell.validate reports 0 errors at every hop. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cadit/step/step_reader.h | 78 +++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index 64de197..963cac4 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -116,6 +116,7 @@ struct TypeLists { std::vector srr; // SHAPE_REPRESENTATION_RELATIONSHIP std::vector cdsr; // CONTEXT_DEPENDENT_SHAPE_REPRESENTATION std::vector sdr; // SHAPE_DEFINITION_REPRESENTATION + std::vector nauo; // NEXT_ASSEMBLY_USAGE_OCCURRENCE (product-tree assembly) std::vector units; // LENGTH_UNIT complex / SI_UNIT }; @@ -606,6 +607,8 @@ class StreamIndex { lists.cdsr.push_back(id); else if (t == "SHAPE_DEFINITION_REPRESENTATION") lists.sdr.push_back(id); + else if (t == "NEXT_ASSEMBLY_USAGE_OCCURRENCE") + lists.nauo.push_back(id); else if (t == "SI_UNIT") lists.units.push_back(id); } @@ -687,7 +690,7 @@ class Resolver { build_colour_map(tl.styled); build_product_name_map(tl.sdr); unit_scale_ = detect_unit_scale(tl.units); // BEFORE build_transform_map — its rep_factor needs it - build_transform_map(tl.roots, tl.absr, tl.srr, tl.cdsr); + build_transform_map(tl.roots, tl.absr, tl.srr, tl.cdsr, tl.nauo, tl.sdr); clear_geom_cache(); } @@ -859,6 +862,8 @@ class Resolver { tl.cdsr.push_back(id); else if (t == "SHAPE_DEFINITION_REPRESENTATION") tl.sdr.push_back(id); + else if (t == "NEXT_ASSEMBLY_USAGE_OCCURRENCE") + tl.nauo.push_back(id); else if (t == "SI_UNIT") tl.units.push_back(id); } @@ -1806,7 +1811,8 @@ class Resolver { } void build_transform_map(const std::vector &root_ids, const std::vector &absr_ids, - const std::vector &srr_ids, const std::vector &cdsr_ids) { + const std::vector &srr_ids, const std::vector &cdsr_ids, + const std::vector &nauo_ids = {}, const std::vector &sdr_ids = {}) { std::unordered_set root_set(root_ids.begin(), root_ids.end()); // Geometry-rep items -> solid. `absr_ids` holds every rep type that can carry // geometry (ABSR, plain SHAPE_REPRESENTATION, ...); a rep counts as a geometry @@ -1924,6 +1930,74 @@ class Resolver { if (nontrivial || mats.size() > 1) // skip pure-identity single instance xform_map_[sid] = std::move(mats); } + + // NAUO product-assembly tree. A STEP file written with baked per-leaf geometry (e.g. our own + // IFC->STEP emit) carries no CDSR placement graph, so world_matrices() gives every solid a + // flat single-level path and the spatial hierarchy collapses on re-import. Recover it from the + // PRODUCT-level NEXT_ASSEMBLY_USAGE_OCCURRENCE chain: rep -> leaf PD (via SDR), child PD -> + // parent PD (via NAUO), walk to the assembly root and override the solid's path with the full + // ancestor chain (leaf level kept as the solid's own (geom_rep, product_name) for consistency + // with the CDSR-derived paths, so the IFC writer zones identically). + if (!nauo_ids.empty()) { + std::unordered_map rep_pd; // rep -> leaf PRODUCT_DEFINITION + for (long id : sdr_ids) { + const Instance *in = inst(id); // SHAPE_DEFINITION_REPRESENTATION(#pds, #rep) + if (!in || in->complex || in->args.size() < 2 || !in->args[0].is_ref() || !in->args[1].is_ref()) + continue; + const Instance *pds = inst(in->args[0].i); // PRODUCT_DEFINITION_SHAPE(name, desc, #pd) + if (pds && pds->args.size() > 2 && pds->args[2].is_ref()) + rep_pd[in->args[1].i] = pds->args[2].i; + } + std::unordered_map nauo_parent; // child PD -> parent PD + for (long id : nauo_ids) { + const Instance *in = inst(id); // NAUO(id,name,desc,#relating_pd,#related_pd,ref) + if (!in || in->complex || in->args.size() < 5 || !in->args[3].is_ref() || !in->args[4].is_ref()) + continue; + nauo_parent[in->args[4].i] = in->args[3].i; // related(child) -> relating(parent) + } + auto pd_name = [&](long pd_id) -> std::string { + const Instance *pd = inst(pd_id); + const Instance *pdf = (pd && pd->args.size() > 2 && pd->args[2].is_ref()) ? inst(pd->args[2].i) : nullptr; + const Instance *prod = (pdf && pdf->args.size() > 2 && pdf->args[2].is_ref()) ? inst(pdf->args[2].i) : nullptr; + if (prod && prod->args.size() >= 2) + for (int k : {1, 0}) // PRODUCT(id, name, ...): prefer name, fall back to id + if (prod->args[k].kind == Kind::Str) { + std::string nm = unescape(prod->args[k].s); + const char *ws = " \t\n\r\f\v"; + size_t a = nm.find_first_not_of(ws), b = nm.find_last_not_of(ws); + if (a != std::string::npos) + return nm.substr(a, b - a + 1); + } + return "asm_" + std::to_string(pd_id); + }; + for (long sid : root_ids) { + auto git = geomrep_of_solid.find(sid); + if (git == geomrep_of_solid.end()) + continue; + auto rit = rep_pd.find(git->second); + if (rit == rep_pd.end()) + continue; + // Walk the ancestor PD chain above the leaf PD (root-first, excluding the leaf). + std::vector> ancestors; + std::unordered_set seen; + long cur = rit->second; // leaf PD + seen.insert(cur); + int guard = 0; + for (auto pit = nauo_parent.find(cur); pit != nauo_parent.end() && guard++ < 64; + pit = nauo_parent.find(cur)) { + cur = pit->second; + if (!seen.insert(cur).second) + break; // cycle guard + ancestors.push_back({cur, pd_name(cur)}); + } + if (ancestors.empty()) + continue; // no assembly nesting -> keep the flat path + std::reverse(ancestors.begin(), ancestors.end()); // root-first + Path path = std::move(ancestors); + path.push_back(path_level(git->second)); // leaf: solid's own (geom_rep, product_name) + path_map_[sid] = {std::move(path)}; + } + } } }; From 06e3eef4889c18d809a072334d860df2f0fa5e4f Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 07:34:36 +0200 Subject: [PATCH 39/65] IFC reader: round IfcIShapeProfileDef web/flange corners with FilletRadius The native I-shape profile emitted a sharp 12-point outline, dropping the FilletRadius (arg 7) so tee-beams lost their rounded web/flange corners (e.g. IPE200 r=12 in beam-varying-cardinal-points.ifc). Add append_fillet_arc and round the four reentrant corners, clamped to min(bx-wx, fy); sharp outline retained when FilletRadius is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 039c00a..40e52f5 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -885,13 +885,30 @@ class IfcResolver { poly = curve_points2d(ref_arg(*in, 2)); // (ProfileType, ProfileName, OuterCurve) } else if (iequals(in->type, "IFCISHAPEPROFILEDEF")) { // (.., Position, OverallWidth, OverallDepth, WebThickness, FlangeThickness, FilletRadius, ...) - // Centred on the bounding box; fillets ignored (sharp corners). Outline CCW from bottom-right. + // Centred on the bounding box. Outline CCW from bottom-right; the four reentrant web/flange + // corners are rounded by FilletRadius (arg 7) when present, else left sharp. double bx = ad(in, 3) / 2, hy = ad(in, 4) / 2, wx = ad(in, 5) / 2, tf = ad(in, 6); if (bx <= 0 || hy <= 0 || wx <= 0 || tf <= 0) return nullptr; double fy = hy - tf; - poly = {{bx, -hy, 0}, {bx, -fy, 0}, {wx, -fy, 0}, {wx, fy, 0}, {bx, fy, 0}, {bx, hy, 0}, - {-bx, hy, 0}, {-bx, fy, 0}, {-wx, fy, 0}, {-wx, -fy, 0}, {-bx, -fy, 0}, {-bx, -hy, 0}}; + double rf = std::min(ad(in, 7), std::min(bx - wx, fy)); // clamp fillet to what fits + if (rf > 1e-9) { + int nf = std::max(3, (int) std::ceil((PI / 2) / 0.30)); // ~17deg chord, like circle_poly + poly = {{bx, -hy, 0}, {bx, -fy, 0}}; + append_fillet_arc(poly, {wx + rf, -fy + rf, 0}, {wx + rf, -fy, 0}, {wx, -fy + rf, 0}, nf); + append_fillet_arc(poly, {wx + rf, fy - rf, 0}, {wx, fy - rf, 0}, {wx + rf, fy, 0}, nf); + poly.push_back({bx, fy, 0}); + poly.push_back({bx, hy, 0}); + poly.push_back({-bx, hy, 0}); + poly.push_back({-bx, fy, 0}); + append_fillet_arc(poly, {-wx - rf, fy - rf, 0}, {-wx - rf, fy, 0}, {-wx, fy - rf, 0}, nf); + append_fillet_arc(poly, {-wx - rf, -fy + rf, 0}, {-wx, -fy + rf, 0}, {-wx - rf, -fy, 0}, nf); + poly.push_back({-bx, -fy, 0}); + poly.push_back({-bx, -hy, 0}); + } else { + poly = {{bx, -hy, 0}, {bx, -fy, 0}, {wx, -fy, 0}, {wx, fy, 0}, {bx, fy, 0}, {bx, hy, 0}, + {-bx, hy, 0}, {-bx, fy, 0}, {-wx, fy, 0}, {-wx, -fy, 0}, {-bx, -fy, 0}, {-bx, -hy, 0}}; + } apply_placement2d(ref_arg(*in, 2), poly); } else if (iequals(in->type, "IFCTSHAPEPROFILEDEF")) { // (.., Position, Depth, FlangeWidth, WebThickness, FlangeThickness, ...). Flange at +y (top), @@ -1000,6 +1017,23 @@ class IfcResolver { } return p; } + // Append a fillet arc (in z=0) sweeping the SHORT way from `from` to `to` about `center`, used to + // round the reentrant web/flange corners of I/T/U profiles (IfcIShapeProfileDef.FilletRadius etc.). + // Both endpoints are emitted; call sites lay out points so no consecutive duplicate is produced. + static void append_fillet_arc(std::vector &out, Vec3 center, Vec3 from, Vec3 to, int n) { + double r = std::hypot(from.x - center.x, from.y - center.y); + double a0 = std::atan2(from.y - center.y, from.x - center.x); + double a1 = std::atan2(to.y - center.y, to.x - center.x); + double d = a1 - a0; + while (d > PI) + d -= TWO_PI; + while (d < -PI) + d += TWO_PI; + for (int i = 0; i <= n; ++i) { + double a = a0 + d * i / n; + out.push_back({center.x + r * std::cos(a), center.y + r * std::sin(a), 0}); + } + } // A 3-point (start, mid, end) circular arc in the z=0 plane, discretized to a polyline (matches // the adapy Python reader's IfcArcIndex -> ArcLine, which the tessellator later rings). Falls // back to the chord [a, m, b] when the three points are collinear (degenerate circumcircle). From 11aaae2b2183d1f4d5ddbb1ba75e85e0c8e377e4 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 10:00:39 +0200 Subject: [PATCH 40/65] =?UTF-8?q?feat:=20OCC-free=20no-pyodide=20IFC?= =?UTF-8?q?=E2=86=92GLB=20embind=20wasm=20module=20(adacpp=5Fifc=5Fglb)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IFC counterpart of adacpp_step_glb: wraps stream_ifc_to_glb (native IfcResolver → libtess2 → meshoptimizer → GLB, no OCC/ifcopenshell/pyodide/ nanobind) as a standalone embind wasm module. Same neutral-geometry + GLB stack, IfcResolver front-end. - src/cad/ifc_glb_wasm.cpp: embind entry (ifcToGlb + mountOpfs), mirroring cad_wasm.cpp; WASMFS/OPFS IO so large IFC streams through pread. - cmake: BUILD_IFC_GLB_WASM option + standalone target (manifold + libtess2 + meshopt + ngeom), short-circuits the OCCT cross-build like step_glb. - pixi: wbuild-ifcglb task. Node smoke-tested (WASMFS in-heap): box_rotated, beam-extruded-solid, bath-csg-solid (CSG via manifold), half_space_beam (half-space clip), with_arc_boundary all produce valid glTF with the expected product counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 5 ++-- cmake/wasm_build.cmake | 28 ++++++++++++++++++++++ pixi.toml | 1 + src/cad/ifc_glb_wasm.cpp | 50 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/cad/ifc_glb_wasm.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f6a6283..67ffee8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ endif() OPTION(BUILD_WASM "Build the project for WebAssembly" OFF) OPTION(BUILD_STEP_GLB_WASM "Build ONLY the OCC-free STEP->GLB wasm module (embind, no OCCT/pyodide/Python)" OFF) OPTION(BUILD_GLB_DIFF_WASM "Build ONLY the OCC-free GLB-diff wasm module (embind, no OCCT/pyodide/Python)" OFF) +OPTION(BUILD_IFC_GLB_WASM "Build ONLY the OCC-free IFC->GLB wasm module (embind, no OCCT/pyodide/Python)" OFF) OPTION(BUILD_STP2GLB_STANDALONE "Build ONLY the OCC-free STP2GLB CLI — skip OCCT/CGAL/gmsh/IFC/Python" OFF) OPTION(BUILD_PYTHON "Build the project for Python" ON) OPTION(BUILD_STP2GLB "Build the STP2GLB executable" ON) @@ -149,8 +150,8 @@ if (BUILD_WASM) # Cross-compile a minimal OCCT to wasm via FetchContent. Sets WASM_OCCT_TARGETS # for the nanobind module / executables to link against. First spike is # FoundationClasses only; module set widens as features land. - # Skipped for the OCC-free STEP->GLB / GLB-diff modules — they need no OCCT, so we avoid the heavy cross-build. - if (NOT BUILD_STEP_GLB_WASM AND NOT BUILD_GLB_DIFF_WASM) + # Skipped for the OCC-free STEP->GLB / IFC->GLB / GLB-diff modules — they need no OCCT, so we avoid the heavy cross-build. + if (NOT BUILD_STEP_GLB_WASM AND NOT BUILD_GLB_DIFF_WASM AND NOT BUILD_IFC_GLB_WASM) include(cmake/wasm_occt.cmake) endif () endif () diff --git a/cmake/wasm_build.cmake b/cmake/wasm_build.cmake index c412d71..7ed2cfb 100644 --- a/cmake/wasm_build.cmake +++ b/cmake/wasm_build.cmake @@ -52,6 +52,34 @@ if (BUILD_STEP_GLB_WASM) return() # standalone target; skip the legacy WASM_UTILS stub below endif () +# The OCC-free native IFC->GLB pipeline as a STANDALONE embind wasm module — no OCCT, no pyodide, no +# ifcopenshell, no nanobind, no Python. The IFC counterpart of adacpp_step_glb: same neutral-geometry +# tessellation + GLB stack (ngeom + libtess2 + meshopt + manifold), IfcResolver front-end. Streams +# the IFC from OPFS via WASMFS so large files never have to fit the wasm heap. +if (BUILD_IFC_GLB_WASM) + add_executable(adacpp_ifc_glb + ${CMAKE_SOURCE_DIR}/src/cad/ifc_glb_wasm.cpp + ${CMAKE_SOURCE_DIR}/src/geom/neutral/ngeom_tessellate.cpp + ${CMAKE_SOURCE_DIR}/src/geom/neutral/ngeom_boolean.cpp + ${CMAKE_SOURCE_DIR}/src/geom/neutral/ngeom_meshopt.cpp + ${LIBTESS2_SOURCES} + ${MESHOPT_SOURCES}) + target_link_libraries(adacpp_ifc_glb PRIVATE manifold) + set_target_properties(adacpp_ifc_glb PROPERTIES OUTPUT_NAME "adacpp_ifc_glb" SUFFIX ".js") + target_link_options(adacpp_ifc_glb PRIVATE + "-lembind" + "-sWASMFS=1" # WASMFS + OPFS backend (file-backed pread, not heap) + "-sFORCE_FILESYSTEM=1" + "-sEXPORTED_RUNTIME_METHODS=['FS']" # JS-side FS for OPFS mount + node smoke test + "-sALLOW_MEMORY_GROWTH=1" + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sEXPORT_NAME=createAdacppIfcGlb" + "-sENVIRONMENT=web,worker,node" + "-sSTACK_SIZE=1048576") + return() # standalone target; skip the legacy WASM_UTILS stub below +endif () + set(WASM_SOURCES src/wasm_utils.cpp) set(WASM_HEADERS src/wasm_utils.h) diff --git a/pixi.toml b/pixi.toml index 4f56e1a..91917bd 100644 --- a/pixi.toml +++ b/pixi.toml @@ -86,6 +86,7 @@ clean = { cmd = "rm -rf build-wasm && mkdir -p build-wasm" } wbuild = { cmd = "emcmake cmake . -B build-wasm -G Ninja -DBUILD_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm", depends-on = ["clean"] } wbuild-stepglb = { cmd = "emcmake cmake . -B build-wasm-stepglb -G Ninja -DBUILD_WASM=ON -DBUILD_STEP_GLB_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-stepglb", description = "Build the OCC-free no-pyodide STEP->GLB embind wasm module (adacpp_step_glb.js + .wasm)" } wbuild-glbdiff = { cmd = "emcmake cmake . -B build-wasm-glbdiff -G Ninja -DBUILD_WASM=ON -DBUILD_GLB_DIFF_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-glbdiff", description = "Build the OCC-free in-browser GLB diff embind wasm module (adacpp_glb_diff.js + .wasm)" } +wbuild-ifcglb = { cmd = "emcmake cmake . -B build-wasm-ifcglb -G Ninja -DBUILD_WASM=ON -DBUILD_IFC_GLB_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-ifcglb", description = "Build the OCC-free no-pyodide IFC->GLB embind wasm module (adacpp_ifc_glb.js + .wasm)" } xbuildenv-install = { cmd = "pyodide xbuildenv install 0.29.4", description = "Install the pyodide cross-build environment (Python 3.13 + emscripten 4.0.9)" } build-occt-wasm = { cmd = "emcmake cmake --preset wasm-pyodide -DPython_INCLUDE_DIR=\"$(pyodide config get python_include_dir)\" && cmake --build build/wasm-pyodide --target occt_external", description = "Cross-build + install ONLY the OCCT-wasm toolkits → build/wasm-pyodide/_deps/occt-install (the reusable base-image input)" } wbuild-pyodide = { cmd = "emcmake cmake --preset wasm-pyodide -DPython_INCLUDE_DIR=\"$(pyodide config get python_include_dir)\" && cmake --build build/wasm-pyodide", description = "Build the adacpp nanobind module for pyodide" } diff --git a/src/cad/ifc_glb_wasm.cpp b/src/cad/ifc_glb_wasm.cpp new file mode 100644 index 0000000..d4fd45f --- /dev/null +++ b/src/cad/ifc_glb_wasm.cpp @@ -0,0 +1,50 @@ +// embind entry for the no-pyodide native IFC -> GLB wasm module. +// +// STANDALONE wasm module: the OCC-free, dep-free native IFC pipeline (IfcResolver Part-21 reader + +// libtess2 + meshoptimizer + GLB writer) compiled with emscripten + embind — NO pyodide, NO Python, +// NO OCCT, NO ifcopenshell, NO nanobind. The IFC counterpart of adacpp_step_glb (cad_wasm.cpp); the +// two share the neutral-geometry tessellation + GLB stack and differ only in the front-end reader. +// +// IO model mirrors the STEP module: both `inPath` and `outPath` live in the emscripten file system. +// Mount OPFS via WASMFS (build with -sWASMFS) and pass OPFS-backed paths so a large IFC streams +// through `pread` (bounded RSS) and the GLB is written back to OPFS. `spillDir` is a writable +// directory (OPFS or MEMFS) for the per-material spill lanes; pass an explicit one (the C++ default +// mkdtemp("/tmp/...") isn't reliable under WASMFS). +// +// Single-threaded (stream_ifc_to_glb uses threads=1), so this links WITHOUT -pthread and runs on any +// page (no SharedArrayBuffer / cross-origin isolation required). + +#include + +#include +#include + +#include "ifc_to_glb_stream.h" + +namespace { + +// Returns the triangle count written, or -1 on error. deflection/angular_deg are the libtess2 chordal +// + angular tolerances (2.0 / 20.0 are the adapy production defaults). The GLB is baked to metres via +// the IFC file's unit scale (viewer default), like the native/nanobind path. +long ifc_to_glb(const std::string &in_path, const std::string &out_path, const std::string &spill_dir, + double deflection, double angular_deg, bool meshopt) { + return adacpp::stream_ifc_to_glb(in_path, out_path, deflection, angular_deg, meshopt, spill_dir); +} + +// Mount the browser's Origin Private File System (OPFS) at `mount_point` so the IFC, the GLB and the +// spill lanes live in OPFS — the IFC streams through pread (bounded RSS) instead of the wasm heap. +// MUST be called from a Web Worker (OPFS sync access handles are worker-only). Returns 0 on success, +// non-zero if OPFS is unavailable; the caller then falls back to the in-heap WASMFS default. +int mount_opfs(const std::string &mount_point) { + backend_t opfs = wasmfs_create_opfs_backend(); + if (!opfs) + return -1; + return wasmfs_create_directory(mount_point.c_str(), 0777, opfs); +} + +} // namespace + +EMSCRIPTEN_BINDINGS(adacpp_ifc_glb) { + emscripten::function("ifcToGlb", &ifc_to_glb); + emscripten::function("mountOpfs", &mount_opfs); +} From c4bc745f8562429ab306b8f486c97adc599ac9cd Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 10:50:14 +0200 Subject: [PATCH 41/65] =?UTF-8?q?feat:=20OCC-free=20no-pyodide=20B-rep=20w?= =?UTF-8?q?riter=20wasm=20module=20(STEP=E2=86=94IFC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wasm counterparts of the native STEP→IFC / IFC→STEP writers, so those conversions can run fully in-browser (no OCC/ifcopenshell/pyodide/Python). To keep ONE implementation, the file→file writers + their shared emit helpers were extracted verbatim out of the nanobind cad_py_wrap.cpp into a new dep-free header src/cad/brep_file_convert.h (namespace adacpp::brep_convert): IfcPath/IfcPath2, emit_solid_ifc, emit_spatial_tree, ifc_header_block, write_ifc_file_impl, step_header_block, emit_solid_step, emit_step_assembly_tree, write_ifc_to_step_impl. cad_py_wrap.cpp now includes it + `using namespace` so its remaining nb:: writers (blobs_to_ifc, *_parallel, write_step_file, step_parity) call the shared helpers unchanged. - src/cad/brep_writer_wasm.cpp: embind (stepToIfc + ifcToStep + mountOpfs); no tessellation, so no libtess2/meshopt/manifold. - cmake: BUILD_BREP_WRITER_WASM standalone target; pixi wbuild-brepwriter. Validated: native module recompiles + relinks clean and its stream_step_to_ifc / stream_ifc_to_step produce correct output (unchanged); the wasm module node-smoke-tests STEP→IFC + IFC→STEP across flat/curved/CSG/Ventilator, with IFC→STEP byte-identical to native. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 6 +- cmake/wasm_build.cmake | 22 ++ pixi.toml | 1 + src/cad/brep_file_convert.h | 490 +++++++++++++++++++++++++++++++++++ src/cad/brep_writer_wasm.cpp | 54 ++++ src/cad/cad_py_wrap.cpp | 457 +------------------------------- 6 files changed, 577 insertions(+), 453 deletions(-) create mode 100644 src/cad/brep_file_convert.h create mode 100644 src/cad/brep_writer_wasm.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 67ffee8..b648cd9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ OPTION(BUILD_WASM "Build the project for WebAssembly" OFF) OPTION(BUILD_STEP_GLB_WASM "Build ONLY the OCC-free STEP->GLB wasm module (embind, no OCCT/pyodide/Python)" OFF) OPTION(BUILD_GLB_DIFF_WASM "Build ONLY the OCC-free GLB-diff wasm module (embind, no OCCT/pyodide/Python)" OFF) OPTION(BUILD_IFC_GLB_WASM "Build ONLY the OCC-free IFC->GLB wasm module (embind, no OCCT/pyodide/Python)" OFF) +OPTION(BUILD_BREP_WRITER_WASM "Build ONLY the OCC-free B-rep writer wasm module (STEP<->IFC, embind, no OCCT/pyodide/Python)" OFF) OPTION(BUILD_STP2GLB_STANDALONE "Build ONLY the OCC-free STP2GLB CLI — skip OCCT/CGAL/gmsh/IFC/Python" OFF) OPTION(BUILD_PYTHON "Build the project for Python" ON) OPTION(BUILD_STP2GLB "Build the STP2GLB executable" ON) @@ -150,8 +151,9 @@ if (BUILD_WASM) # Cross-compile a minimal OCCT to wasm via FetchContent. Sets WASM_OCCT_TARGETS # for the nanobind module / executables to link against. First spike is # FoundationClasses only; module set widens as features land. - # Skipped for the OCC-free STEP->GLB / IFC->GLB / GLB-diff modules — they need no OCCT, so we avoid the heavy cross-build. - if (NOT BUILD_STEP_GLB_WASM AND NOT BUILD_GLB_DIFF_WASM AND NOT BUILD_IFC_GLB_WASM) + # Skipped for the OCC-free standalone embind modules (STEP->GLB / IFC->GLB / GLB-diff / B-rep + # writer) — they need no OCCT, so we avoid the heavy cross-build. + if (NOT BUILD_STEP_GLB_WASM AND NOT BUILD_GLB_DIFF_WASM AND NOT BUILD_IFC_GLB_WASM AND NOT BUILD_BREP_WRITER_WASM) include(cmake/wasm_occt.cmake) endif () endif () diff --git a/cmake/wasm_build.cmake b/cmake/wasm_build.cmake index 7ed2cfb..774fd5c 100644 --- a/cmake/wasm_build.cmake +++ b/cmake/wasm_build.cmake @@ -80,6 +80,28 @@ if (BUILD_IFC_GLB_WASM) return() # standalone target; skip the legacy WASM_UTILS stub below endif () +# The OCC-free native B-rep WRITER (STEP→IFC + IFC→STEP) as a STANDALONE embind wasm module — no +# OCCT, no pyodide, no ifcopenshell, no nanobind, no Python. No tessellation (writers operate on the +# analytic NgeomRoot), so unlike the →GLB modules it needs NO libtess2 / meshopt / manifold. Shares one +# implementation with the nanobind module via brep_file_convert.h. Streams source from OPFS via WASMFS. +if (BUILD_BREP_WRITER_WASM) + add_executable(adacpp_brep_writer + ${CMAKE_SOURCE_DIR}/src/cad/brep_writer_wasm.cpp) + set_target_properties(adacpp_brep_writer PROPERTIES OUTPUT_NAME "adacpp_brep_writer" SUFFIX ".js") + target_link_options(adacpp_brep_writer PRIVATE + "-lembind" + "-sWASMFS=1" # WASMFS + OPFS backend (file-backed pread, not heap) + "-sFORCE_FILESYSTEM=1" + "-sEXPORTED_RUNTIME_METHODS=['FS']" # JS-side FS for OPFS mount + node smoke test + "-sALLOW_MEMORY_GROWTH=1" + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sEXPORT_NAME=createAdacppBrepWriter" + "-sENVIRONMENT=web,worker,node" + "-sSTACK_SIZE=1048576") + return() # standalone target; skip the legacy WASM_UTILS stub below +endif () + set(WASM_SOURCES src/wasm_utils.cpp) set(WASM_HEADERS src/wasm_utils.h) diff --git a/pixi.toml b/pixi.toml index 91917bd..8cccd84 100644 --- a/pixi.toml +++ b/pixi.toml @@ -87,6 +87,7 @@ wbuild = { cmd = "emcmake cmake . -B build-wasm -G Ninja -DBUILD_WASM=ON -DBUILD wbuild-stepglb = { cmd = "emcmake cmake . -B build-wasm-stepglb -G Ninja -DBUILD_WASM=ON -DBUILD_STEP_GLB_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-stepglb", description = "Build the OCC-free no-pyodide STEP->GLB embind wasm module (adacpp_step_glb.js + .wasm)" } wbuild-glbdiff = { cmd = "emcmake cmake . -B build-wasm-glbdiff -G Ninja -DBUILD_WASM=ON -DBUILD_GLB_DIFF_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-glbdiff", description = "Build the OCC-free in-browser GLB diff embind wasm module (adacpp_glb_diff.js + .wasm)" } wbuild-ifcglb = { cmd = "emcmake cmake . -B build-wasm-ifcglb -G Ninja -DBUILD_WASM=ON -DBUILD_IFC_GLB_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-ifcglb", description = "Build the OCC-free no-pyodide IFC->GLB embind wasm module (adacpp_ifc_glb.js + .wasm)" } +wbuild-brepwriter = { cmd = "emcmake cmake . -B build-wasm-brepwriter -G Ninja -DBUILD_WASM=ON -DBUILD_BREP_WRITER_WASM=ON -DBUILD_PYTHON=OFF -DBUILD_TESTS=OFF -DBUILD_STP2GLB=OFF && cmake --build build-wasm-brepwriter", description = "Build the OCC-free no-pyodide B-rep writer embind wasm module (STEP<->IFC: adacpp_brep_writer.js + .wasm)" } xbuildenv-install = { cmd = "pyodide xbuildenv install 0.29.4", description = "Install the pyodide cross-build environment (Python 3.13 + emscripten 4.0.9)" } build-occt-wasm = { cmd = "emcmake cmake --preset wasm-pyodide -DPython_INCLUDE_DIR=\"$(pyodide config get python_include_dir)\" && cmake --build build/wasm-pyodide --target occt_external", description = "Cross-build + install ONLY the OCCT-wasm toolkits → build/wasm-pyodide/_deps/occt-install (the reusable base-image input)" } wbuild-pyodide = { cmd = "emcmake cmake --preset wasm-pyodide -DPython_INCLUDE_DIR=\"$(pyodide config get python_include_dir)\" && cmake --build build/wasm-pyodide", description = "Build the adacpp nanobind module for pyodide" } diff --git a/src/cad/brep_file_convert.h b/src/cad/brep_file_convert.h new file mode 100644 index 0000000..5c41bc4 --- /dev/null +++ b/src/cad/brep_file_convert.h @@ -0,0 +1,490 @@ +// Shared, dep-free B-rep file→file writers (STEP↔IFC), extracted from cad_py_wrap.cpp so BOTH the +// nanobind module AND the OCC-free embind wasm writer (brep_writer_wasm.cpp) call ONE implementation. +// No nanobind / OCC / ifcopenshell — only the native readers (StreamIndex/Resolver, IfcResolver) and +// the dep-free emitters (ifc_emit::BrepEmitter, step_emit::StepBrepEmitter). See ifc_to_glb_stream.h +// for the sibling pattern. +// +// Contents (moved verbatim; `static` → `inline` for the header): +// STEP→IFC: IfcPath, emit_solid_ifc, emit_spatial_tree, ifc_length_unit_line, ifc_header_block, +// write_ifc_file_impl +// IFC→STEP: step_header_block, emit_solid_step, IfcPath2, emit_step_assembly_tree, +// write_ifc_to_step_impl +// The nanobind file's remaining writers (blobs_to_ifc_impl, *_parallel_impl, write_step_file_impl, +// step_parity_impl) call these via `using namespace adacpp::brep_convert;`. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../cadit/step/step_reader.h" +#include "../geom/neutral/ngeom_decode.h" +#include "../geom/neutral/ngeom_profile.h" +#include "ifc_emit.h" +#include "ifc_reader.h" +#include "step_emit.h" + +namespace adacpp::brep_convert { + +// Emit ONE solid's IFC into `buf` via `em` (its id counter must be pre-seeded — to a continuous +// running id for the serial path, or to the solid's disjoint id block for the parallel path). Shared +// by both writers. Flat solids (transforms empty) -> one IfcBuildingElementProxy; instanced solids -> +// one IfcRepresentationMap + one IfcMappedItem+IfcCartesianTransformationOperator3D+proxy per world +// placement (geometry shared). Appends every proxy id to `proxies`. guid_seed must be unique per +// solid (instances use guid_seed+k; reserve a gap > max instances). +using IfcPath = std::vector>; // root-first (rep_id, product_name) levels + +inline void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, const adacpp::ngeom::NgeomRoot &root, + long sid, uint64_t guid_seed, std::vector &proxies, + std::vector *proxy_paths) { + using namespace adacpp::ifc_emit; + std::string rep_type = "AdvancedBrep"; + long solid = em.emit_solid(buf, root, rep_type); // brep for face sets, else the analytic IFC solid + if (!solid) + return; + // Presentation colour: style the shared solid item so it colours every (mapped) instance. The + // NgeomRoot already carries the colour resolved by the STEP/IFC reader (has_color/cr/cg/cb/ca) — + // emit it as IfcStyledItem -> IfcSurfaceStyle -> IfcColourRgb (was dropped; the viewer fell back + // to grey). Matches adapy's add_colour + the Python streaming writer. + if (root.has_color) { + auto R = [](float v) { return adacpp::ifc_emit::ifc_real(v); }; + long col = em.emit_entity(buf, "IfcColourRgb($," + R(root.cr) + "," + R(root.cg) + "," + R(root.cb) + ")"); + long sh = em.emit_entity(buf, "IfcSurfaceStyleShading(#" + std::to_string(col) + "," + R(1.0f - root.ca) + ")"); + long ss = em.emit_entity(buf, "IfcSurfaceStyle($,.BOTH.,(#" + std::to_string(sh) + "))"); + em.emit_entity(buf, "IfcStyledItem(#" + std::to_string(solid) + ",(#" + std::to_string(ss) + "),$)"); + } + std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : ifc_str(root.id); + auto record = [&](long proxy, size_t k) { + proxies.push_back(proxy); + if (proxy_paths) + proxy_paths->push_back(k < root.instance_paths.size() ? root.instance_paths[k] : IfcPath{}); + }; + // Proxy Name = the solid's own (leaf) product name; the assembly PATH is carried by proxy_paths + // and emitted as a nested IfcElementAssembly tree by emit_spatial_tree (aligned with the STEP->GLB + // product tree). Falls back to root.id when there's no path. + auto leaf_name = [&](size_t k) -> std::string { + if (k < root.instance_paths.size() && !root.instance_paths[k].empty()) { + const std::string &ln = root.instance_paths[k].back().second; + if (!ln.empty()) + return ifc_str(ln); + } + return nm; + }; + long mrep = + em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','" + rep_type + "',(#" + std::to_string(solid) + "))"); + if (root.transforms.empty()) { + long pds = em.emit_entity(buf, "IfcProductDefinitionShape($,$,(#" + std::to_string(mrep) + "))"); + long proxy = em.emit_entity(buf, "IfcBuildingElementProxy('" + ifc_guid(guid_seed) + "',$,'" + leaf_name(0) + + "',$,$,#11,#" + std::to_string(pds) + ",$,$)"); + record(proxy, 0); + return; + } + long repmap = em.emit_entity(buf, "IfcRepresentationMap(#4,#" + std::to_string(mrep) + ")"); + int k = 0; + for (const auto &T : root.transforms) { + auto R = [](float v) { return ifc_real(v); }; + auto D = [&](float a, float b, float cc) { + return em.emit_entity(buf, "IfcDirection((" + R(a) + "," + R(b) + "," + R(cc) + "))"); + }; + long axx = D(T[0], T[1], T[2]), axy = D(T[4], T[5], T[6]), axz = D(T[8], T[9], T[10]); + long org = em.emit_entity(buf, "IfcCartesianPoint((" + R(T[12]) + "," + R(T[13]) + "," + R(T[14]) + "))"); + long op = em.emit_entity(buf, "IfcCartesianTransformationOperator3D(#" + std::to_string(axx) + ",#" + + std::to_string(axy) + ",#" + std::to_string(org) + ",1.,#" + + std::to_string(axz) + ")"); + long mi = em.emit_entity(buf, "IfcMappedItem(#" + std::to_string(repmap) + ",#" + std::to_string(op) + ")"); + long sr = em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','MappedRepresentation',(#" + + std::to_string(mi) + "))"); + long pds = em.emit_entity(buf, "IfcProductDefinitionShape($,$,(#" + std::to_string(sr) + "))"); + long proxy = em.emit_entity(buf, "IfcBuildingElementProxy('" + ifc_guid(guid_seed + 1 + k) + "',$,'" + + leaf_name(k) + "',$,$,#11,#" + std::to_string(pds) + ",$,$)"); + record(proxy, k); + ++k; + } +} + +// Emit the nested IfcElementAssembly tree from per-proxy assembly paths, aligned with the STEP->GLB +// product tree (scene_from_step_stream._group_parent): assembly nodes keyed by the path's REP-ID +// prefix (names repeat across branches), named by product name. The proxy (leaf) is aggregated under +// its deepest assembly via IfcRelAggregates; assemblies nest via IfcRelAggregates; top-level elements +// (top assemblies + path-less proxies) are contained in the storey (#14). `next_id` allocates entity +// ids; appends SPF to `out`. +inline void emit_spatial_tree(std::string &out, const std::function &next_id, const std::vector &proxies, + const std::vector &paths) { + using adacpp::ifc_emit::ifc_guid; + using adacpp::ifc_emit::ifc_str; + // Generic, domain-neutral spatial hierarchy: each assembly-path level is an IfcSpatialZone (not a + // building-specific IfcBuildingStorey / element-grouping IfcElementAssembly). Zones nest under the + // root zone (#12) via IfcRelAggregates; the leaf elements are contained in their deepest zone via + // IfcRelContainedInSpatialStructure (IfcSpatialZone is a valid IfcSpatialElement RelatingStructure). + const long ROOT = 12; // the header's root IfcSpatialZone (under IfcProject) + std::map, long> zone_id; // rep-id prefix -> IfcSpatialZone id + std::map> agg_children; // parent zone -> child zones (IfcRelAggregates) + std::map> contained; // zone -> contained leaf elements (IfcRelContained…) + uint64_t aguid = 0xE0000000ull; // zone/rel GUID namespace (disjoint from header 0xF.. + proxies) + + for (size_t i = 0; i < proxies.size(); ++i) { + const IfcPath &path = (i < paths.size()) ? paths[i] : IfcPath{}; + // Intermediate spatial levels = path[0 .. n-2]; path.back() is the solid's own (leaf) product. + long parent_zone = ROOT; + std::vector prefix; + for (size_t d = 0; d + 1 < path.size(); ++d) { + prefix.push_back(path[d].first); + auto it = zone_id.find(prefix); + long zid; + if (it == zone_id.end()) { + zid = next_id(); + zone_id[prefix] = zid; + std::string zn = + path[d].second.empty() ? ("zone_" + std::to_string(path[d].first)) : ifc_str(path[d].second); + out += "#" + std::to_string(zid) + "=IFCSPATIALZONE('" + ifc_guid(aguid++) + "',$,'" + zn + + "',$,$,#11,$,$,.NOTDEFINED.);\n"; + agg_children[parent_zone].push_back(zid); + } else { + zid = it->second; + } + parent_zone = zid; + } + contained[parent_zone].push_back(proxies[i]); + } + for (const auto &[pid, kids] : agg_children) { + std::string refs = "("; + for (size_t j = 0; j < kids.size(); ++j) + refs += (j ? ",#" : "#") + std::to_string(kids[j]); + refs += ")"; + out += "#" + std::to_string(next_id()) + "=IFCRELAGGREGATES('" + ifc_guid(aguid++) + "',$,$,$,#" + + std::to_string(pid) + "," + refs + ");\n"; + } + for (const auto &[zid, kids] : contained) { + std::string refs = "("; + for (size_t j = 0; j < kids.size(); ++j) + refs += (j ? ",#" : "#") + std::to_string(kids[j]); + refs += ")"; + out += "#" + std::to_string(next_id()) + "=IFCRELCONTAINEDINSPATIALSTRUCTURE('" + ifc_guid(aguid++) + + "',$,$,$," + refs + ",#" + std::to_string(zid) + ");\n"; + } +} + +// Shared IFC header + spatial block (ids #1..#13). schema = "IFC4X3_ADD2" | "IFC4". +// The #7 length-unit line for the header. ng:: geometry is kept in the file's NATIVE units (the +// mesh/glb path scales by unit_scale at bake time; the IFC writer emits native coords), so declare +// the matching unit. unit_scale = metres per file-unit. SI prefixes cover m/mm/cm/dm/km/µm (≈ every +// real STEP file); a non-SI scale (e.g. inch 0.0254) keeps METRE (no regression — was always METRE) +// and is logged by the caller. Stays a SINGLE entity at #7 so the fixed #1..#13 header id layout holds. +inline std::string ifc_length_unit_line(double unit_scale) { + auto close = [](double a, double b) { return std::abs(a - b) <= 1e-9 * std::max(1.0, std::abs(b)); }; + const char *prefix = nullptr; // null => plain METRE + if (close(unit_scale, 1e-3)) + prefix = ".MILLI."; + else if (close(unit_scale, 1e-2)) + prefix = ".CENTI."; + else if (close(unit_scale, 1e-1)) + prefix = ".DECI."; + else if (close(unit_scale, 1e3)) + prefix = ".KILO."; + else if (close(unit_scale, 1e-6)) + prefix = ".MICRO."; + std::string pf = prefix ? prefix : "$"; + return "#7=IFCSIUNIT(*,.LENGTHUNIT.," + pf + ",.METRE.);\n#8=IFCUNITASSIGNMENT((#7));\n"; +} + +inline std::string ifc_header_block(const std::string &schema, double unit_scale) { + using adacpp::ifc_emit::ifc_guid; + std::string b; + b += "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n"; + b += "FILE_NAME('','',(''),(''),'adacpp','','');\nFILE_SCHEMA(('" + schema + "'));\nENDSEC;\nDATA;\n"; + // Generic, domain-neutral spatial hierarchy: IfcProject -> a root IfcSpatialZone (#12), aggregated + // by IfcRelAggregates (#13). Assembly-path levels become nested IfcSpatialZones under #12 and the + // leaf elements are contained in their deepest zone (emit_spatial_tree). No building semantics. + // Reserved header ids #1..#13 (K) — keep in sync with the parallel writer's K + renumber threshold. + b += "#1=IFCCARTESIANPOINT((0.,0.,0.));\n#2=IFCDIRECTION((0.,0.,1.));\n#3=IFCDIRECTION((1.,0.,0.));\n" + "#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);\n#5=IFCDIRECTION((1.,0.));\n" + "#6=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#4,#5);\n"; + b += ifc_length_unit_line(unit_scale); + b += "#9=IFCPROJECT('" + ifc_guid(0xF0000001ull) + "',$,'adacpp model',$,$,$,$,(#6),#8);\n"; + b += "#10=IFCAXIS2PLACEMENT3D(#1,$,$);\n#11=IFCLOCALPLACEMENT($,#10);\n"; + b += "#12=IFCSPATIALZONE('" + ifc_guid(0xF0000002ull) + "',$,'Model',$,$,#11,$,$,.NOTDEFINED.);\n"; + b += "#13=IFCRELAGGREGATES('" + ifc_guid(0xF0000005ull) + "',$,$,$,#9,(#12));\n"; + return b; +} + +// Phase 2 STEP->IFC file writer: drives the native reader (StreamIndex/Resolver) + the dep-free +// ifc_emit::BrepEmitter. Lives here (not in ifc_emit.h) so the emitter header stays reader-free. +// Streams to disk per chunk; bounds parse_cache_ (single-threaded -> safe per fc37d71). +inline adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_path, const std::string &out_path, + const std::string &schema, double deflection, double angular_deg, + long max_solids) { + using namespace adacpp::ifc_emit; + FileStats fs; + adacpp::prof::StepProfiler prof("stream_step_to_ifc(st)"); + auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); + adacpp::step::Resolver r(idx); + r.build_metadata(idx.lists); + r.enable_parse_cache_bounding(); + fs.unit_scale = r.unit_scale(); + prof.phase("metadata"); + std::FILE *fp = std::fopen(out_path.c_str(), "wb"); + if (!fp) + return fs; + std::string buf; + buf.reserve(1 << 22); + auto flush = [&](bool force) { + if (buf.size() >= (4u << 20) || force) { + std::fwrite(buf.data(), 1, buf.size(), fp); + buf.clear(); + } + }; + buf += ifc_header_block(schema, r.unit_scale()); + BrepEmitter em(100, nullptr, deflection, angular_deg); + std::vector proxies; + std::vector proxy_paths; + for (long sid : idx.lists.roots) { + if (max_solids > 0 && fs.solids_in >= max_solids) + break; + adacpp::ngeom::NgeomRoot root = r.resolve_root(sid); + ++fs.solids_in; + prof.solid(root.faces.size()); + size_t before = proxies.size(); + emit_solid_ifc(em, buf, root, sid, (uint64_t) fs.solids_in * 1000u, proxies, &proxy_paths); + if (proxies.size() > before) + ++fs.solids_out; + r.clear_geom_cache(); + flush(false); + } + prof.phase("resolve+emit"); + emit_spatial_tree(buf, [&]() { return em.alloc_id(); }, proxies, proxy_paths); + buf += "ENDSEC;\nEND-ISO-10303-21;\n"; + flush(true); + std::fclose(fp); + prof.phase("spatial_tree+write"); + fs.geom = em.stats(); + return fs; +} + +// AP242 STEP header + shared context block (ids #1..#13). unit_scale -> SI length prefix (matches the +// IFC writer's mapping). K=13 reserved. +inline std::string step_header_block(double unit_scale) { + auto close = [](double a, double b) { return std::abs(a - b) <= 1e-9 * std::max(1.0, std::abs(b)); }; + const char *prefix = "$"; // plain METRE + if (close(unit_scale, 1e-3)) + prefix = ".MILLI."; + else if (close(unit_scale, 1e-2)) + prefix = ".CENTI."; + else if (close(unit_scale, 1e-1)) + prefix = ".DECI."; + else if (close(unit_scale, 1e3)) + prefix = ".KILO."; + else if (close(unit_scale, 1e-6)) + prefix = ".MICRO."; + std::string b; + b += "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('adacpp native STEP->STEP'),'2;1');\n"; + b += "FILE_NAME('','',(''),(''),'adacpp','','');\n"; + b += "FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));\n"; + b += "ENDSEC;\nDATA;\n"; + b += "#1=APPLICATION_CONTEXT('managed model based 3d engineering');\n"; + b += "#2=APPLICATION_PROTOCOL_DEFINITION('international standard'," + "'ap242_managed_model_based_3d_engineering_mim_lf',2014,#1);\n"; + b += "#3=PRODUCT_CONTEXT('',#1,'mechanical');\n#4=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');\n"; + b += "#5=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(" + std::string(prefix) + ",.METRE.));\n"; + b += "#6=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n"; + b += "#7=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());\n"; + b += "#8=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#5,'distance_accuracy_value','edge/vertex');\n"; + b += "#9=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#8))" + "GLOBAL_UNIT_ASSIGNED_CONTEXT((#5,#6,#7))REPRESENTATION_CONTEXT('Context','3D'));\n"; + b += "#10=CARTESIAN_POINT('',(0.,0.,0.));\n#11=DIRECTION('',(0.,0.,1.));\n#12=DIRECTION('',(1.,0.,0.));\n"; + b += "#13=AXIS2_PLACEMENT_3D('',#10,#11,#12);\n"; + return b; +} + +// Emit one solid instance (geometry baked by `em`'s transform) as a self-contained AP242 part: +// MANIFOLD_SOLID_BREP / EXTRUDED_AREA_SOLID -> (ADVANCED_BREP_)SHAPE_REPRESENTATION (placement #13, +// context #9) -> PRODUCT chain -> SHAPE_DEFINITION_REPRESENTATION. Returns true if a solid was emitted. +// Returns the leaf PRODUCT_DEFINITION id (0 on failure) so the caller can hang a NEXT_ASSEMBLY_USAGE_ +// OCCURRENCE assembly tree off it; treat nonzero as success. +inline long emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, + const adacpp::ngeom::NgeomRoot &root, long sid) { + std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : adacpp::ifc_emit::ifc_str(root.id); + long solid = 0; + const char *rep_kw = "ADVANCED_BREP_SHAPE_REPRESENTATION"; + if (root.extrusion) { + bool hollow = root.extrusion->profile && root.extrusion->profile->bounds.size() > 1; + if (em.tf_rigid() && !hollow) { // rigid, solid profile -> native EXTRUDED_AREA_SOLID + solid = em.emit_extrusion(buf, *root.extrusion); + rep_kw = "SHAPE_REPRESENTATION"; + } else { // scale/shear or hollow profile -> bake to a B-rep (annular caps for voids) + solid = em.emit_extrusion_baked(buf, *root.extrusion, nm); + } + } else if (root.revolve) { // non-rigid revolves are dropped to OCC by the reader -> rigid here + solid = em.emit_revolve(buf, *root.revolve); + rep_kw = "SHAPE_REPRESENTATION"; + } else if (root.sweep) { // disk/annulus swept along a directrix -> SWEPT_DISK_SOLID + solid = em.emit_swept_disk(buf, *root.sweep); + rep_kw = "SHAPE_REPRESENTATION"; + } else if (root.sphere) { // CSG sphere primitive + solid = em.emit_sphere(buf, *root.sphere); + rep_kw = "SHAPE_REPRESENTATION"; + } else if (root.boolean) { // CSG tree (BOOLEAN_RESULT), preserved not evaluated + solid = em.emit_boolean(buf, *root.boolean); + rep_kw = "SHAPE_REPRESENTATION"; + } else { + solid = em.emit_manifold_brep(buf, root, nm); + } + if (!solid) + return 0; + long rep = em.emit_entity(buf, std::string(rep_kw) + "('" + nm + "',(#13,#" + std::to_string(solid) + "),#9)"); + long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); + long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); + long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); + long pds = em.emit_entity(buf, "PRODUCT_DEFINITION_SHAPE('','',#" + std::to_string(pd) + ")"); + em.emit_entity(buf, "SHAPE_DEFINITION_REPRESENTATION(#" + std::to_string(pds) + ",#" + std::to_string(rep) + ")"); + return pd; +} + +// Emit a NEXT_ASSEMBLY_USAGE_OCCURRENCE assembly tree from per-leaf (product_definition, path): each +// intermediate path level becomes an assembly PRODUCT_DEFINITION, linked to its children (assemblies or +// leaf solids) by a NAUO — the STEP counterpart of emit_spatial_tree, so the IFC/STEP hierarchy round- +// trips. Assembly nodes carry no own shape (grouping only); placements stay baked into the leaf geometry. +using IfcPath2 = std::vector>; +inline void emit_step_assembly_tree(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, + const std::vector &leaf_pds, const std::vector &paths) { + using adacpp::ifc_emit::ifc_str; + std::map, long> asm_pd; // rep-id prefix -> assembly PRODUCT_DEFINITION id + auto asm_node = [&](const std::vector &prefix, const std::string &name) -> long { + auto it = asm_pd.find(prefix); + if (it != asm_pd.end()) + return it->second; + std::string nm = name.empty() ? ("asm_" + std::to_string(prefix.back())) : ifc_str(name); + long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); + long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); + long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); + asm_pd[prefix] = pd; + return pd; + }; + auto nauo = [&](long parent_pd, long child_pd, const std::string &nm) { + em.emit_entity(buf, "NEXT_ASSEMBLY_USAGE_OCCURRENCE('','" + nm + "','',#" + std::to_string(parent_pd) + ",#" + + std::to_string(child_pd) + ",$)"); + }; + for (size_t i = 0; i < leaf_pds.size(); ++i) { + if (!leaf_pds[i]) + continue; + const IfcPath2 &path = (i < paths.size()) ? paths[i] : IfcPath2{}; + long parent_pd = 0; + std::vector prefix; + for (size_t d = 0; d + 1 < path.size(); ++d) { + prefix.push_back(path[d].first); + long apd = asm_node(prefix, path[d].second); + if (parent_pd) + nauo(parent_pd, apd, path[d].second); + parent_pd = apd; + } + if (parent_pd) // hang the leaf solid under its deepest assembly (top-level leaves stay roots) + nauo(parent_pd, leaf_pds[i], path.empty() ? "" : path.back().second); + } +} + +// Native IFC->STEP (AP242): IfcResolver reads each product's analytic B-rep -> ng::, then the STEP +// emitter re-writes it (instances baked). Serial (per-product resolve is cheap; the shared +// IfcRepresentationMap brep is re-resolved per instance = baked). Round-trip with the STEP->IFC writer. +inline adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_path, const std::string &out_path, + double deflection, double angular_deg, long max_solids) { + using adacpp::ngeom::NgeomRoot; + using adacpp::step_emit::StepBrepEmitter; + adacpp::ifc_emit::FileStats fs; + adacpp::prof::StepProfiler prof("stream_ifc_to_step"); + auto idx = adacpp::step::StreamIndex::from_file(in_path); + prof.phase("scan_index"); + if (!idx.ok()) + return fs; + adacpp::ifc_read::IfcResolver r(idx); + fs.unit_scale = r.unit_scale(); + std::vector roots = r.proxy_roots(); + if (max_solids > 0 && (long) roots.size() > max_solids) + roots.resize(max_solids); + fs.products_total = (long) roots.size(); + prof.phase("metadata"); + std::FILE *fp = std::fopen(out_path.c_str(), "wb"); + if (!fp) + return fs; + std::string buf; + buf.reserve(1 << 22); + std::string hdr = step_header_block(fs.unit_scale); + std::fwrite(hdr.data(), 1, hdr.size(), fp); + long nid = 13; // continue after the shared header block + auto flush = [&](bool force) { + if (buf.size() >= (4u << 20) || force) { + std::fwrite(buf.data(), 1, buf.size(), fp); + buf.clear(); + } + }; + std::vector leaf_pds; // per emitted solid: its PRODUCT_DEFINITION id (for the NAUO tree) + std::vector leaf_paths; // parallel: the solid's assembly path + for (long pid : roots) { + NgeomRoot root = r.resolve_product(pid); + if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean && !root.sweep && !root.sphere) { + ++fs.products_skipped; // a product whose geometry the analytic reader couldn't represent + continue; + } + ++fs.solids_in; + prof.solid(root.faces.size()); + size_t ninst = root.transforms.empty() ? 1 : root.transforms.size(); + bool any = false; + for (size_t k = 0; k < ninst; ++k) { + double tf[16]; + const double *tfp = nullptr; + if (!root.transforms.empty()) { + const std::array &M = root.transforms[k]; + tf[0] = M[0]; + tf[1] = M[4]; + tf[2] = M[8]; + tf[3] = M[12]; + tf[4] = M[1]; + tf[5] = M[5]; + tf[6] = M[9]; + tf[7] = M[13]; + tf[8] = M[2]; + tf[9] = M[6]; + tf[10] = M[10]; + tf[11] = M[14]; + tfp = tf; + } + StepBrepEmitter em(nid, tfp, deflection, angular_deg); + long pd = emit_solid_step(em, buf, root, pid); + if (pd) { + nid = em.current_id(); + any = true; + leaf_pds.push_back(pd); + leaf_paths.push_back(k < root.instance_paths.size() ? root.instance_paths[k] : IfcPath2{}); + const auto &s = em.stats(); + fs.geom.faces_in += s.faces_in; + fs.geom.faces_out += s.faces_out; + fs.geom.faces_dropped += s.faces_dropped; + fs.geom.edges_analytic += s.edges_analytic; + fs.geom.edges_polyline_approx += s.edges_polyline_approx; + fs.geom.edges_degenerate += s.edges_degenerate; + for (const auto &[rk, rv] : s.drop_reasons) + fs.geom.drop_reasons[rk] += rv; + } + } + if (any) + ++fs.solids_out; + flush(false); + } + prof.phase("resolve+emit"); + // NAUO assembly tree over all emitted leaves (STEP counterpart of the IFC spatial tree). + StepBrepEmitter emtree(nid, nullptr, deflection, angular_deg); + emit_step_assembly_tree(emtree, buf, leaf_pds, leaf_paths); + const char *foot = "ENDSEC;\nEND-ISO-10303-21;\n"; + buf += foot; + flush(true); + std::fclose(fp); + prof.phase("write_tail"); + return fs; +} + +} // namespace adacpp::brep_convert diff --git a/src/cad/brep_writer_wasm.cpp b/src/cad/brep_writer_wasm.cpp new file mode 100644 index 0000000..3fca6f3 --- /dev/null +++ b/src/cad/brep_writer_wasm.cpp @@ -0,0 +1,54 @@ +// embind entry for the no-pyodide native B-rep WRITER wasm module (STEP→IFC + IFC→STEP). +// +// STANDALONE wasm module: the OCC-free, dep-free native B-rep writers (StreamIndex/Resolver + +// IfcResolver readers → ifc_emit/step_emit emitters) compiled with emscripten + embind — NO pyodide, +// NO Python, NO OCCT, NO ifcopenshell, NO nanobind. Shares ONE implementation with the nanobind +// module via brep_file_convert.h (adacpp::brep_convert::write_ifc_file_impl / write_ifc_to_step_impl). +// +// IO mirrors the CAD→GLB modules: both paths live in the emscripten FS. Mount OPFS via WASMFS (build +// with -sWASMFS) and pass OPFS-backed paths so a large STEP/IFC streams through `pread` (bounded RSS) +// and the output is written back to OPFS. Single-threaded (the writers are serial), so this links +// WITHOUT -pthread and runs on any page (no SharedArrayBuffer / cross-origin isolation required). + +#include + +#include +#include + +#include "brep_file_convert.h" + +namespace { + +// STEP → IFC. schema = "IFC4X3_ADD2" | "IFC4". Returns the number of solids written (>0 success, +// 0 = nothing emitted / I/O error). deflection/angular_deg only affect face-set fallbacks. +long step_to_ifc(const std::string &in_path, const std::string &out_path, const std::string &schema, + double deflection, double angular_deg, long max_solids) { + adacpp::ifc_emit::FileStats fs = + adacpp::brep_convert::write_ifc_file_impl(in_path, out_path, schema, deflection, angular_deg, max_solids); + return fs.solids_out; +} + +// IFC → STEP (AP242). Returns the number of products written (>0 success, 0 = nothing / I/O error). +long ifc_to_step(const std::string &in_path, const std::string &out_path, double deflection, double angular_deg, + long max_solids) { + adacpp::ifc_emit::FileStats fs = + adacpp::brep_convert::write_ifc_to_step_impl(in_path, out_path, deflection, angular_deg, max_solids); + return fs.solids_out; +} + +// Mount the browser's Origin Private File System (OPFS) at `mount_point` (worker-only). Returns 0 on +// success, non-zero if OPFS is unavailable; the caller falls back to the in-heap WASMFS default. +int mount_opfs(const std::string &mount_point) { + backend_t opfs = wasmfs_create_opfs_backend(); + if (!opfs) + return -1; + return wasmfs_create_directory(mount_point.c_str(), 0777, opfs); +} + +} // namespace + +EMSCRIPTEN_BINDINGS(adacpp_brep_writer) { + emscripten::function("stepToIfc", &step_to_ifc); + emscripten::function("ifcToStep", &ifc_to_step); + emscripten::function("mountOpfs", &mount_opfs); +} diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 9313121..ca1ec8d 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -19,6 +19,7 @@ #include "step_to_mesh_stream.h" // threaded OCC-free STEP->STL/OBJ core (parallel, baked, streaming) #include "ifc_emit.h" // native IFC4 advanced-B-rep emitter (Phase 1, native STEP->IFC writer) #include "step_emit.h" // native AP242 STEP advanced-B-rep emitter (native STEP->STEP writer) +#include "brep_file_convert.h" // shared STEP<->IFC file writers (also compiled into the wasm writer) #include "ifc_reader.h" // native IFC advanced-B-rep reader -> ng:: (native IFC->STEP path) #include "../diff/glb_diff_native.h" // GLB model-diff core (summary match + removed overlay) @@ -2801,240 +2802,12 @@ nb::bytes meshopt_decode_index_sequence_impl(nb::bytes enc, size_t count, size_t } // namespace -// Emit ONE solid's IFC into `buf` via `em` (its id counter must be pre-seeded — to a continuous -// running id for the serial path, or to the solid's disjoint id block for the parallel path). Shared -// by both writers. Flat solids (transforms empty) -> one IfcBuildingElementProxy; instanced solids -> -// one IfcRepresentationMap + one IfcMappedItem+IfcCartesianTransformationOperator3D+proxy per world -// placement (geometry shared). Appends every proxy id to `proxies`. guid_seed must be unique per -// solid (instances use guid_seed+k; reserve a gap > max instances). -using IfcPath = std::vector>; // root-first (rep_id, product_name) levels - -static void emit_solid_ifc(adacpp::ifc_emit::BrepEmitter &em, std::string &buf, const adacpp::ngeom::NgeomRoot &root, - long sid, uint64_t guid_seed, std::vector &proxies, - std::vector *proxy_paths) { - using namespace adacpp::ifc_emit; - std::string rep_type = "AdvancedBrep"; - long solid = em.emit_solid(buf, root, rep_type); // brep for face sets, else the analytic IFC solid - if (!solid) - return; - // Presentation colour: style the shared solid item so it colours every (mapped) instance. The - // NgeomRoot already carries the colour resolved by the STEP/IFC reader (has_color/cr/cg/cb/ca) — - // emit it as IfcStyledItem -> IfcSurfaceStyle -> IfcColourRgb (was dropped; the viewer fell back - // to grey). Matches adapy's add_colour + the Python streaming writer. - if (root.has_color) { - auto R = [](float v) { return adacpp::ifc_emit::ifc_real(v); }; - long col = em.emit_entity(buf, "IfcColourRgb($," + R(root.cr) + "," + R(root.cg) + "," + R(root.cb) + ")"); - long sh = em.emit_entity(buf, "IfcSurfaceStyleShading(#" + std::to_string(col) + "," + R(1.0f - root.ca) + ")"); - long ss = em.emit_entity(buf, "IfcSurfaceStyle($,.BOTH.,(#" + std::to_string(sh) + "))"); - em.emit_entity(buf, "IfcStyledItem(#" + std::to_string(solid) + ",(#" + std::to_string(ss) + "),$)"); - } - std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : ifc_str(root.id); - auto record = [&](long proxy, size_t k) { - proxies.push_back(proxy); - if (proxy_paths) - proxy_paths->push_back(k < root.instance_paths.size() ? root.instance_paths[k] : IfcPath{}); - }; - // Proxy Name = the solid's own (leaf) product name; the assembly PATH is carried by proxy_paths - // and emitted as a nested IfcElementAssembly tree by emit_spatial_tree (aligned with the STEP->GLB - // product tree). Falls back to root.id when there's no path. - auto leaf_name = [&](size_t k) -> std::string { - if (k < root.instance_paths.size() && !root.instance_paths[k].empty()) { - const std::string &ln = root.instance_paths[k].back().second; - if (!ln.empty()) - return ifc_str(ln); - } - return nm; - }; - long mrep = - em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','" + rep_type + "',(#" + std::to_string(solid) + "))"); - if (root.transforms.empty()) { - long pds = em.emit_entity(buf, "IfcProductDefinitionShape($,$,(#" + std::to_string(mrep) + "))"); - long proxy = em.emit_entity(buf, "IfcBuildingElementProxy('" + ifc_guid(guid_seed) + "',$,'" + leaf_name(0) + - "',$,$,#11,#" + std::to_string(pds) + ",$,$)"); - record(proxy, 0); - return; - } - long repmap = em.emit_entity(buf, "IfcRepresentationMap(#4,#" + std::to_string(mrep) + ")"); - int k = 0; - for (const auto &T : root.transforms) { - auto R = [](float v) { return ifc_real(v); }; - auto D = [&](float a, float b, float cc) { - return em.emit_entity(buf, "IfcDirection((" + R(a) + "," + R(b) + "," + R(cc) + "))"); - }; - long axx = D(T[0], T[1], T[2]), axy = D(T[4], T[5], T[6]), axz = D(T[8], T[9], T[10]); - long org = em.emit_entity(buf, "IfcCartesianPoint((" + R(T[12]) + "," + R(T[13]) + "," + R(T[14]) + "))"); - long op = em.emit_entity(buf, "IfcCartesianTransformationOperator3D(#" + std::to_string(axx) + ",#" + - std::to_string(axy) + ",#" + std::to_string(org) + ",1.,#" + - std::to_string(axz) + ")"); - long mi = em.emit_entity(buf, "IfcMappedItem(#" + std::to_string(repmap) + ",#" + std::to_string(op) + ")"); - long sr = em.emit_entity(buf, "IfcShapeRepresentation(#6,'Body','MappedRepresentation',(#" + - std::to_string(mi) + "))"); - long pds = em.emit_entity(buf, "IfcProductDefinitionShape($,$,(#" + std::to_string(sr) + "))"); - long proxy = em.emit_entity(buf, "IfcBuildingElementProxy('" + ifc_guid(guid_seed + 1 + k) + "',$,'" + - leaf_name(k) + "',$,$,#11,#" + std::to_string(pds) + ",$,$)"); - record(proxy, k); - ++k; - } -} - -// Emit the nested IfcElementAssembly tree from per-proxy assembly paths, aligned with the STEP->GLB -// product tree (scene_from_step_stream._group_parent): assembly nodes keyed by the path's REP-ID -// prefix (names repeat across branches), named by product name. The proxy (leaf) is aggregated under -// its deepest assembly via IfcRelAggregates; assemblies nest via IfcRelAggregates; top-level elements -// (top assemblies + path-less proxies) are contained in the storey (#14). `next_id` allocates entity -// ids; appends SPF to `out`. -static void emit_spatial_tree(std::string &out, const std::function &next_id, const std::vector &proxies, - const std::vector &paths) { - using adacpp::ifc_emit::ifc_guid; - using adacpp::ifc_emit::ifc_str; - // Generic, domain-neutral spatial hierarchy: each assembly-path level is an IfcSpatialZone (not a - // building-specific IfcBuildingStorey / element-grouping IfcElementAssembly). Zones nest under the - // root zone (#12) via IfcRelAggregates; the leaf elements are contained in their deepest zone via - // IfcRelContainedInSpatialStructure (IfcSpatialZone is a valid IfcSpatialElement RelatingStructure). - const long ROOT = 12; // the header's root IfcSpatialZone (under IfcProject) - std::map, long> zone_id; // rep-id prefix -> IfcSpatialZone id - std::map> agg_children; // parent zone -> child zones (IfcRelAggregates) - std::map> contained; // zone -> contained leaf elements (IfcRelContained…) - uint64_t aguid = 0xE0000000ull; // zone/rel GUID namespace (disjoint from header 0xF.. + proxies) - - for (size_t i = 0; i < proxies.size(); ++i) { - const IfcPath &path = (i < paths.size()) ? paths[i] : IfcPath{}; - // Intermediate spatial levels = path[0 .. n-2]; path.back() is the solid's own (leaf) product. - long parent_zone = ROOT; - std::vector prefix; - for (size_t d = 0; d + 1 < path.size(); ++d) { - prefix.push_back(path[d].first); - auto it = zone_id.find(prefix); - long zid; - if (it == zone_id.end()) { - zid = next_id(); - zone_id[prefix] = zid; - std::string zn = - path[d].second.empty() ? ("zone_" + std::to_string(path[d].first)) : ifc_str(path[d].second); - out += "#" + std::to_string(zid) + "=IFCSPATIALZONE('" + ifc_guid(aguid++) + "',$,'" + zn + - "',$,$,#11,$,$,.NOTDEFINED.);\n"; - agg_children[parent_zone].push_back(zid); - } else { - zid = it->second; - } - parent_zone = zid; - } - contained[parent_zone].push_back(proxies[i]); - } - for (const auto &[pid, kids] : agg_children) { - std::string refs = "("; - for (size_t j = 0; j < kids.size(); ++j) - refs += (j ? ",#" : "#") + std::to_string(kids[j]); - refs += ")"; - out += "#" + std::to_string(next_id()) + "=IFCRELAGGREGATES('" + ifc_guid(aguid++) + "',$,$,$,#" + - std::to_string(pid) + "," + refs + ");\n"; - } - for (const auto &[zid, kids] : contained) { - std::string refs = "("; - for (size_t j = 0; j < kids.size(); ++j) - refs += (j ? ",#" : "#") + std::to_string(kids[j]); - refs += ")"; - out += "#" + std::to_string(next_id()) + "=IFCRELCONTAINEDINSPATIALSTRUCTURE('" + ifc_guid(aguid++) + - "',$,$,$," + refs + ",#" + std::to_string(zid) + ");\n"; - } -} - -// Shared IFC header + spatial block (ids #1..#13). schema = "IFC4X3_ADD2" | "IFC4". -// The #7 length-unit line for the header. ng:: geometry is kept in the file's NATIVE units (the -// mesh/glb path scales by unit_scale at bake time; the IFC writer emits native coords), so declare -// the matching unit. unit_scale = metres per file-unit. SI prefixes cover m/mm/cm/dm/km/µm (≈ every -// real STEP file); a non-SI scale (e.g. inch 0.0254) keeps METRE (no regression — was always METRE) -// and is logged by the caller. Stays a SINGLE entity at #7 so the fixed #1..#13 header id layout holds. -static std::string ifc_length_unit_line(double unit_scale) { - auto close = [](double a, double b) { return std::abs(a - b) <= 1e-9 * std::max(1.0, std::abs(b)); }; - const char *prefix = nullptr; // null => plain METRE - if (close(unit_scale, 1e-3)) - prefix = ".MILLI."; - else if (close(unit_scale, 1e-2)) - prefix = ".CENTI."; - else if (close(unit_scale, 1e-1)) - prefix = ".DECI."; - else if (close(unit_scale, 1e3)) - prefix = ".KILO."; - else if (close(unit_scale, 1e-6)) - prefix = ".MICRO."; - std::string pf = prefix ? prefix : "$"; - return "#7=IFCSIUNIT(*,.LENGTHUNIT.," + pf + ",.METRE.);\n#8=IFCUNITASSIGNMENT((#7));\n"; -} - -static std::string ifc_header_block(const std::string &schema, double unit_scale) { - using adacpp::ifc_emit::ifc_guid; - std::string b; - b += "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n"; - b += "FILE_NAME('','',(''),(''),'adacpp','','');\nFILE_SCHEMA(('" + schema + "'));\nENDSEC;\nDATA;\n"; - // Generic, domain-neutral spatial hierarchy: IfcProject -> a root IfcSpatialZone (#12), aggregated - // by IfcRelAggregates (#13). Assembly-path levels become nested IfcSpatialZones under #12 and the - // leaf elements are contained in their deepest zone (emit_spatial_tree). No building semantics. - // Reserved header ids #1..#13 (K) — keep in sync with the parallel writer's K + renumber threshold. - b += "#1=IFCCARTESIANPOINT((0.,0.,0.));\n#2=IFCDIRECTION((0.,0.,1.));\n#3=IFCDIRECTION((1.,0.,0.));\n" - "#4=IFCAXIS2PLACEMENT3D(#1,#2,#3);\n#5=IFCDIRECTION((1.,0.));\n" - "#6=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#4,#5);\n"; - b += ifc_length_unit_line(unit_scale); - b += "#9=IFCPROJECT('" + ifc_guid(0xF0000001ull) + "',$,'adacpp model',$,$,$,$,(#6),#8);\n"; - b += "#10=IFCAXIS2PLACEMENT3D(#1,$,$);\n#11=IFCLOCALPLACEMENT($,#10);\n"; - b += "#12=IFCSPATIALZONE('" + ifc_guid(0xF0000002ull) + "',$,'Model',$,$,#11,$,$,.NOTDEFINED.);\n"; - b += "#13=IFCRELAGGREGATES('" + ifc_guid(0xF0000005ull) + "',$,$,$,#9,(#12));\n"; - return b; -} -// Phase 2 STEP->IFC file writer: drives the native reader (StreamIndex/Resolver) + the dep-free -// ifc_emit::BrepEmitter. Lives here (not in ifc_emit.h) so the emitter header stays reader-free. -// Streams to disk per chunk; bounds parse_cache_ (single-threaded -> safe per fc37d71). -static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_path, const std::string &out_path, - const std::string &schema, double deflection, double angular_deg, - long max_solids) { - using namespace adacpp::ifc_emit; - FileStats fs; - adacpp::prof::StepProfiler prof("stream_step_to_ifc(st)"); - auto idx = adacpp::step::StreamIndex::from_file(in_path); - prof.phase("scan_index"); - adacpp::step::Resolver r(idx); - r.build_metadata(idx.lists); - r.enable_parse_cache_bounding(); - fs.unit_scale = r.unit_scale(); - prof.phase("metadata"); - std::FILE *fp = std::fopen(out_path.c_str(), "wb"); - if (!fp) - return fs; - std::string buf; - buf.reserve(1 << 22); - auto flush = [&](bool force) { - if (buf.size() >= (4u << 20) || force) { - std::fwrite(buf.data(), 1, buf.size(), fp); - buf.clear(); - } - }; - buf += ifc_header_block(schema, r.unit_scale()); - BrepEmitter em(100, nullptr, deflection, angular_deg); - std::vector proxies; - std::vector proxy_paths; - for (long sid : idx.lists.roots) { - if (max_solids > 0 && fs.solids_in >= max_solids) - break; - adacpp::ngeom::NgeomRoot root = r.resolve_root(sid); - ++fs.solids_in; - prof.solid(root.faces.size()); - size_t before = proxies.size(); - emit_solid_ifc(em, buf, root, sid, (uint64_t) fs.solids_in * 1000u, proxies, &proxy_paths); - if (proxies.size() > before) - ++fs.solids_out; - r.clear_geom_cache(); - flush(false); - } - prof.phase("resolve+emit"); - emit_spatial_tree(buf, [&]() { return em.alloc_id(); }, proxies, proxy_paths); - buf += "ENDSEC;\nEND-ISO-10303-21;\n"; - flush(true); - std::fclose(fp); - prof.phase("spatial_tree+write"); - fs.geom = em.stats(); - return fs; -} +// B-rep file→file writers (STEP↔IFC) moved to a shared dep-free header so the embind wasm writer +// (brep_writer_wasm.cpp) reuses ONE implementation. Brings emit_solid_ifc / emit_spatial_tree / +// ifc_header_block / write_ifc_file_impl / step_header_block / emit_solid_step / emit_step_assembly_tree +// / write_ifc_to_step_impl (+ IfcPath / IfcPath2) into scope for the nb:: writers that still call them. +using namespace adacpp::brep_convert; // Native IFC writer from NGEOM blobs (the lazy ShapeStore form) + their out-of-band metadata (colour, // world transforms, spatial paths). The ada-object-model counterpart of write_ifc_file_impl (which @@ -3331,126 +3104,6 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin return fs; } -// AP242 STEP header + shared context block (ids #1..#13). unit_scale -> SI length prefix (matches the -// IFC writer's mapping). K=13 reserved. -static std::string step_header_block(double unit_scale) { - auto close = [](double a, double b) { return std::abs(a - b) <= 1e-9 * std::max(1.0, std::abs(b)); }; - const char *prefix = "$"; // plain METRE - if (close(unit_scale, 1e-3)) - prefix = ".MILLI."; - else if (close(unit_scale, 1e-2)) - prefix = ".CENTI."; - else if (close(unit_scale, 1e-1)) - prefix = ".DECI."; - else if (close(unit_scale, 1e3)) - prefix = ".KILO."; - else if (close(unit_scale, 1e-6)) - prefix = ".MICRO."; - std::string b; - b += "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('adacpp native STEP->STEP'),'2;1');\n"; - b += "FILE_NAME('','',(''),(''),'adacpp','','');\n"; - b += "FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));\n"; - b += "ENDSEC;\nDATA;\n"; - b += "#1=APPLICATION_CONTEXT('managed model based 3d engineering');\n"; - b += "#2=APPLICATION_PROTOCOL_DEFINITION('international standard'," - "'ap242_managed_model_based_3d_engineering_mim_lf',2014,#1);\n"; - b += "#3=PRODUCT_CONTEXT('',#1,'mechanical');\n#4=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');\n"; - b += "#5=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(" + std::string(prefix) + ",.METRE.));\n"; - b += "#6=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n"; - b += "#7=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());\n"; - b += "#8=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#5,'distance_accuracy_value','edge/vertex');\n"; - b += "#9=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#8))" - "GLOBAL_UNIT_ASSIGNED_CONTEXT((#5,#6,#7))REPRESENTATION_CONTEXT('Context','3D'));\n"; - b += "#10=CARTESIAN_POINT('',(0.,0.,0.));\n#11=DIRECTION('',(0.,0.,1.));\n#12=DIRECTION('',(1.,0.,0.));\n"; - b += "#13=AXIS2_PLACEMENT_3D('',#10,#11,#12);\n"; - return b; -} - -// Emit one solid instance (geometry baked by `em`'s transform) as a self-contained AP242 part: -// MANIFOLD_SOLID_BREP / EXTRUDED_AREA_SOLID -> (ADVANCED_BREP_)SHAPE_REPRESENTATION (placement #13, -// context #9) -> PRODUCT chain -> SHAPE_DEFINITION_REPRESENTATION. Returns true if a solid was emitted. -// Returns the leaf PRODUCT_DEFINITION id (0 on failure) so the caller can hang a NEXT_ASSEMBLY_USAGE_ -// OCCURRENCE assembly tree off it; treat nonzero as success. -static long emit_solid_step(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, - const adacpp::ngeom::NgeomRoot &root, long sid) { - std::string nm = root.id.empty() ? ("solid_" + std::to_string(sid)) : adacpp::ifc_emit::ifc_str(root.id); - long solid = 0; - const char *rep_kw = "ADVANCED_BREP_SHAPE_REPRESENTATION"; - if (root.extrusion) { - bool hollow = root.extrusion->profile && root.extrusion->profile->bounds.size() > 1; - if (em.tf_rigid() && !hollow) { // rigid, solid profile -> native EXTRUDED_AREA_SOLID - solid = em.emit_extrusion(buf, *root.extrusion); - rep_kw = "SHAPE_REPRESENTATION"; - } else { // scale/shear or hollow profile -> bake to a B-rep (annular caps for voids) - solid = em.emit_extrusion_baked(buf, *root.extrusion, nm); - } - } else if (root.revolve) { // non-rigid revolves are dropped to OCC by the reader -> rigid here - solid = em.emit_revolve(buf, *root.revolve); - rep_kw = "SHAPE_REPRESENTATION"; - } else if (root.sweep) { // disk/annulus swept along a directrix -> SWEPT_DISK_SOLID - solid = em.emit_swept_disk(buf, *root.sweep); - rep_kw = "SHAPE_REPRESENTATION"; - } else if (root.sphere) { // CSG sphere primitive - solid = em.emit_sphere(buf, *root.sphere); - rep_kw = "SHAPE_REPRESENTATION"; - } else if (root.boolean) { // CSG tree (BOOLEAN_RESULT), preserved not evaluated - solid = em.emit_boolean(buf, *root.boolean); - rep_kw = "SHAPE_REPRESENTATION"; - } else { - solid = em.emit_manifold_brep(buf, root, nm); - } - if (!solid) - return 0; - long rep = em.emit_entity(buf, std::string(rep_kw) + "('" + nm + "',(#13,#" + std::to_string(solid) + "),#9)"); - long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); - long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); - long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); - long pds = em.emit_entity(buf, "PRODUCT_DEFINITION_SHAPE('','',#" + std::to_string(pd) + ")"); - em.emit_entity(buf, "SHAPE_DEFINITION_REPRESENTATION(#" + std::to_string(pds) + ",#" + std::to_string(rep) + ")"); - return pd; -} - -// Emit a NEXT_ASSEMBLY_USAGE_OCCURRENCE assembly tree from per-leaf (product_definition, path): each -// intermediate path level becomes an assembly PRODUCT_DEFINITION, linked to its children (assemblies or -// leaf solids) by a NAUO — the STEP counterpart of emit_spatial_tree, so the IFC/STEP hierarchy round- -// trips. Assembly nodes carry no own shape (grouping only); placements stay baked into the leaf geometry. -using IfcPath2 = std::vector>; -static void emit_step_assembly_tree(adacpp::step_emit::StepBrepEmitter &em, std::string &buf, - const std::vector &leaf_pds, const std::vector &paths) { - using adacpp::ifc_emit::ifc_str; - std::map, long> asm_pd; // rep-id prefix -> assembly PRODUCT_DEFINITION id - auto asm_node = [&](const std::vector &prefix, const std::string &name) -> long { - auto it = asm_pd.find(prefix); - if (it != asm_pd.end()) - return it->second; - std::string nm = name.empty() ? ("asm_" + std::to_string(prefix.back())) : ifc_str(name); - long product = em.emit_entity(buf, "PRODUCT('" + nm + "','" + nm + "','',(#3))"); - long pdf = em.emit_entity(buf, "PRODUCT_DEFINITION_FORMATION('','',#" + std::to_string(product) + ")"); - long pd = em.emit_entity(buf, "PRODUCT_DEFINITION('design','',#" + std::to_string(pdf) + ",#4)"); - asm_pd[prefix] = pd; - return pd; - }; - auto nauo = [&](long parent_pd, long child_pd, const std::string &nm) { - em.emit_entity(buf, "NEXT_ASSEMBLY_USAGE_OCCURRENCE('','" + nm + "','',#" + std::to_string(parent_pd) + ",#" + - std::to_string(child_pd) + ",$)"); - }; - for (size_t i = 0; i < leaf_pds.size(); ++i) { - if (!leaf_pds[i]) - continue; - const IfcPath2 &path = (i < paths.size()) ? paths[i] : IfcPath2{}; - long parent_pd = 0; - std::vector prefix; - for (size_t d = 0; d + 1 < path.size(); ++d) { - prefix.push_back(path[d].first); - long apd = asm_node(prefix, path[d].second); - if (parent_pd) - nauo(parent_pd, apd, path[d].second); - parent_pd = apd; - } - if (parent_pd) // hang the leaf solid under its deepest assembly (top-level leaves stay roots) - nauo(parent_pd, leaf_pds[i], path.empty() ? "" : path.back().second); - } -} // Native parallel STEP->STEP (AP242). Mirrors the parallel IFC writer: shared StreamIndex, per-worker // Resolver, LPT, per-worker lanes, local ids -> atomic-reserve -> renumber. Instances are BAKED @@ -3784,104 +3437,6 @@ static void step_parity_impl(const std::string &in_path, double deflection, doub total_instances_out = total_instances.load(); } -// Native IFC->STEP (AP242): IfcResolver reads each product's analytic B-rep -> ng::, then the STEP -// emitter re-writes it (instances baked). Serial (per-product resolve is cheap; the shared -// IfcRepresentationMap brep is re-resolved per instance = baked). Round-trip with the STEP->IFC writer. -static adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_path, const std::string &out_path, - double deflection, double angular_deg, long max_solids) { - using adacpp::ngeom::NgeomRoot; - using adacpp::step_emit::StepBrepEmitter; - adacpp::ifc_emit::FileStats fs; - adacpp::prof::StepProfiler prof("stream_ifc_to_step"); - auto idx = adacpp::step::StreamIndex::from_file(in_path); - prof.phase("scan_index"); - if (!idx.ok()) - return fs; - adacpp::ifc_read::IfcResolver r(idx); - fs.unit_scale = r.unit_scale(); - std::vector roots = r.proxy_roots(); - if (max_solids > 0 && (long) roots.size() > max_solids) - roots.resize(max_solids); - fs.products_total = (long) roots.size(); - prof.phase("metadata"); - std::FILE *fp = std::fopen(out_path.c_str(), "wb"); - if (!fp) - return fs; - std::string buf; - buf.reserve(1 << 22); - std::string hdr = step_header_block(fs.unit_scale); - std::fwrite(hdr.data(), 1, hdr.size(), fp); - long nid = 13; // continue after the shared header block - auto flush = [&](bool force) { - if (buf.size() >= (4u << 20) || force) { - std::fwrite(buf.data(), 1, buf.size(), fp); - buf.clear(); - } - }; - std::vector leaf_pds; // per emitted solid: its PRODUCT_DEFINITION id (for the NAUO tree) - std::vector leaf_paths; // parallel: the solid's assembly path - for (long pid : roots) { - NgeomRoot root = r.resolve_product(pid); - if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean && !root.sweep && !root.sphere) { - ++fs.products_skipped; // a product whose geometry the analytic reader couldn't represent - continue; - } - ++fs.solids_in; - prof.solid(root.faces.size()); - size_t ninst = root.transforms.empty() ? 1 : root.transforms.size(); - bool any = false; - for (size_t k = 0; k < ninst; ++k) { - double tf[16]; - const double *tfp = nullptr; - if (!root.transforms.empty()) { - const std::array &M = root.transforms[k]; - tf[0] = M[0]; - tf[1] = M[4]; - tf[2] = M[8]; - tf[3] = M[12]; - tf[4] = M[1]; - tf[5] = M[5]; - tf[6] = M[9]; - tf[7] = M[13]; - tf[8] = M[2]; - tf[9] = M[6]; - tf[10] = M[10]; - tf[11] = M[14]; - tfp = tf; - } - StepBrepEmitter em(nid, tfp, deflection, angular_deg); - long pd = emit_solid_step(em, buf, root, pid); - if (pd) { - nid = em.current_id(); - any = true; - leaf_pds.push_back(pd); - leaf_paths.push_back(k < root.instance_paths.size() ? root.instance_paths[k] : IfcPath2{}); - const auto &s = em.stats(); - fs.geom.faces_in += s.faces_in; - fs.geom.faces_out += s.faces_out; - fs.geom.faces_dropped += s.faces_dropped; - fs.geom.edges_analytic += s.edges_analytic; - fs.geom.edges_polyline_approx += s.edges_polyline_approx; - fs.geom.edges_degenerate += s.edges_degenerate; - for (const auto &[rk, rv] : s.drop_reasons) - fs.geom.drop_reasons[rk] += rv; - } - } - if (any) - ++fs.solids_out; - flush(false); - } - prof.phase("resolve+emit"); - // NAUO assembly tree over all emitted leaves (STEP counterpart of the IFC spatial tree). - StepBrepEmitter emtree(nid, nullptr, deflection, angular_deg); - emit_step_assembly_tree(emtree, buf, leaf_pds, leaf_paths); - const char *foot = "ENDSEC;\nEND-ISO-10303-21;\n"; - buf += foot; - flush(true); - std::fclose(fp); - prof.phase("write_tail"); - return fs; -} void cad_module(nb::module_ &m) { // Kernel-agnostic mesh / color / group types live in cad — they're the From 69aedec298d1072eebb98b99c45f6ae575ddafa0 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 10:55:39 +0200 Subject: [PATCH 42/65] ci: publish adacpp_ifc_glb + adacpp_brep_writer in the wasm-base image The publish-wasm-base workflow now also builds the IFC->GLB and STEP<->IFC B-rep writer embind modules (wbuild-ifcglb / wbuild-brepwriter) and stages their .js/.wasm into out/wasm/. COPY out/ /out/ carries them into the base image alongside step_glb + glb_diff, so downstream (adapy viewer) picks them up via its existing /out/wasm/* glob. Takes effect on the next adacpp release tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish-wasm-base.yaml | 18 ++++++++++++++++-- deploy/Dockerfile.wasm-base | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-wasm-base.yaml b/.github/workflows/publish-wasm-base.yaml index e89163d..7e0e4b4 100644 --- a/.github/workflows/publish-wasm-base.yaml +++ b/.github/workflows/publish-wasm-base.yaml @@ -78,7 +78,17 @@ jobs: # diff utility. OCC-free -> a quick emcc build. run: pixi run -e wasm wbuild-glbdiff - - name: Stage /out (wheel + manifest + sha + step-glb + glb-diff wasm) + - name: Build the no-pyodide IFC->GLB wasm module + # Standalone embind module (no OCCT, no pyodide, no ifcopenshell) for the viewer's in-browser + # IFC->GLB (IfcResolver -> libtess2 -> GLB). OCC-free -> a quick emcc build. + run: pixi run -e wasm wbuild-ifcglb + + - name: Build the no-pyodide B-rep writer wasm module + # Standalone embind module (no OCCT, no pyodide, no ifcopenshell) for the viewer's in-browser + # STEP<->IFC writers (no tessellation). OCC-free -> a quick emcc build. + run: pixi run -e wasm wbuild-brepwriter + + - name: Stage /out (wheel + manifest + sha + step-glb + glb-diff + ifc-glb + brep-writer wasm) run: | mkdir -p out out/wasm cp dist/ada_cpp-*.whl out/ @@ -86,11 +96,15 @@ jobs: cp build-wasm-stepglb/wasm_output/adacpp_step_glb.wasm out/wasm/ cp build-wasm-glbdiff/wasm_output/adacpp_glb_diff.js out/wasm/ cp build-wasm-glbdiff/wasm_output/adacpp_glb_diff.wasm out/wasm/ + cp build-wasm-ifcglb/wasm_output/adacpp_ifc_glb.js out/wasm/ + cp build-wasm-ifcglb/wasm_output/adacpp_ifc_glb.wasm out/wasm/ + cp build-wasm-brepwriter/wasm_output/adacpp_brep_writer.js out/wasm/ + cp build-wasm-brepwriter/wasm_output/adacpp_brep_writer.wasm out/wasm/ cd out wheel=$(ls ada_cpp-*.whl) printf '{"adacpp":"%s"}\n' "$wheel" > manifest.json printf '%s\n' "${GITHUB_SHA}" > adacpp.sha - echo "Staged $wheel + out/wasm/ (adacpp_step_glb + adacpp_glb_diff .js/.wasm)" + echo "Staged $wheel + out/wasm/ (adacpp_step_glb + adacpp_glb_diff + adacpp_ifc_glb + adacpp_brep_writer .js/.wasm)" - uses: docker/setup-buildx-action@v4 diff --git a/deploy/Dockerfile.wasm-base b/deploy/Dockerfile.wasm-base index 67e6cb0..78491fa 100644 --- a/deploy/Dockerfile.wasm-base +++ b/deploy/Dockerfile.wasm-base @@ -13,8 +13,10 @@ # ada_cpp--cp313-cp313-pyodide_2025_0_wasm32.whl # manifest.json {"adacpp": ""} # adacpp.sha the resolved adacpp commit -# wasm/adacpp_step_glb.{js,wasm} no-pyodide STEP->GLB embind module (viewer in-browser convert) -# wasm/adacpp_glb_diff.{js,wasm} no-pyodide GLB-diff embind module (viewer in-browser diff utility) +# wasm/adacpp_step_glb.{js,wasm} no-pyodide STEP->GLB embind module (viewer in-browser convert) +# wasm/adacpp_glb_diff.{js,wasm} no-pyodide GLB-diff embind module (viewer in-browser diff utility) +# wasm/adacpp_ifc_glb.{js,wasm} no-pyodide IFC->GLB embind module (viewer in-browser convert) +# wasm/adacpp_brep_writer.{js,wasm} no-pyodide STEP<->IFC B-rep writer embind module # # busybox (not scratch) so `docker create` + `docker cp` work for consumers # that extract /out/ without a running command. From 4af3d9927ed78874d31b703aa7c1ea3528f5bfa3 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 15:34:00 +0200 Subject: [PATCH 43/65] fix: gate STEP NAUO path override to the flat-path (no-CDSR) fallback only b90d338 recovered the assembly hierarchy from the NAUO product tree for files with no CDSR placement graph (our baked IFC->STEP emit), but it fired for ANY file carrying NAUO entities and unconditionally overwrote the CDSR-derived multi-level instance_paths. On a real assembly STEP with a full CDSR graph (the crane: 10795 NAUO + 10795 CDSR) that replaced good shared paths with deep per-solid NAUO chains, exploding build_scene_extras during GLB write: write_glb_merged 5s -> 141s and peak RSS +718 MB -- the crane STEP->GLB 90s -> 253s regression. Gate the override to solids whose CDSR/world_matrices path is still FLAT (size <= 1) -- exactly the baked-file case it targets. Files with a real CDSR hierarchy keep their fast paths; the as1-oc-214 IFC->STEP->IFC roundtrip (no CDSR) still recovers its assembly tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cadit/step/step_reader.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index 963cac4..c37cf35 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -1974,6 +1974,17 @@ class Resolver { auto git = geomrep_of_solid.find(sid); if (git == geomrep_of_solid.end()) continue; + // Only recover hierarchy from NAUO when CDSR/world_matrices left this solid with a + // FLAT path (no assembly nesting) — the baked per-leaf case this fallback targets. A + // file with a real CDSR placement graph (e.g. the crane: 10795 CDSR) already has + // correct multi-level paths; overwriting them with the deep NAUO ancestor chain is + // redundant AND, on a deep/wide assembly, explodes the GLB scene-hierarchy build + // (build_scene_extras): ~5x slower + ~700 MB heavier on the crane STEP->GLB. + auto exist = path_map_.find(sid); + if (exist != path_map_.end() && + std::any_of(exist->second.begin(), exist->second.end(), + [](const Path &p) { return p.size() > 1; })) + continue; // already has a real CDSR hierarchy — keep it auto rit = rep_pd.find(git->second); if (rit == rep_pd.end()) continue; From 415c5cc3567c16792a236c880e8c72b77c43aca8 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 15:34:13 +0200 Subject: [PATCH 44/65] perf: trim streaming-tessellation peak RSS via a moderate malloc mmap/trim threshold The streaming tessellator churns large transient buffers per solid (the resolved B-rep + libtess2's per-face DCEL). By default glibc keeps those freed blocks in the arena free-lists instead of returning them to the OS, so peak RSS tracks retained-but-dead memory rather than the live set. Set M_MMAP_THRESHOLD / M_TRIM_THRESHOLD to 4 MB so large freed blocks round-trip through mmap and unmap on free; the many small libtess2 allocations stay in-arena (fast). Measured (crane STEP->GLB, 39.7M tris, 3 workers): 3009 MB / 157s (default) -> 2866 MB / 159s (-143 MB, +1% time). Larger reclaim is expected on tessellation- working-set-heavy models (few tris, complex faces) whose peak is transient DCEL churn rather than one solid's live mesh. A more aggressive 128 KB threshold or M_ARENA_MAX=2 reclaim ~560-630 MB but triple the heavy-solid tessellation time (mmap/lock thrashing) -- rejected. M_ARENA_MAX alone is a no-op. Set ADACPP_NO_MALLOC_TUNE to disable. Applied once at every streaming entry point (STEP->GLB/mesh, STEP->IFC/STEP, IFC->STEP). glibc-only; no-op on macOS / Windows / wasm. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/brep_file_convert.h | 2 ++ src/cad/cad_py_wrap.cpp | 3 +++ src/cad/mem_tune.h | 44 +++++++++++++++++++++++++++++++++++ src/cad/step_to_glb_stream.h | 2 ++ src/cad/step_to_mesh_stream.h | 2 ++ 5 files changed, 53 insertions(+) create mode 100644 src/cad/mem_tune.h diff --git a/src/cad/brep_file_convert.h b/src/cad/brep_file_convert.h index 5c41bc4..fa20881 100644 --- a/src/cad/brep_file_convert.h +++ b/src/cad/brep_file_convert.h @@ -26,6 +26,7 @@ #include "../cadit/step/step_reader.h" #include "../geom/neutral/ngeom_decode.h" #include "../geom/neutral/ngeom_profile.h" +#include "mem_tune.h" #include "ifc_emit.h" #include "ifc_reader.h" #include "step_emit.h" @@ -397,6 +398,7 @@ inline adacpp::ifc_emit::FileStats write_ifc_to_step_impl(const std::string &in_ using adacpp::step_emit::StepBrepEmitter; adacpp::ifc_emit::FileStats fs; adacpp::prof::StepProfiler prof("stream_ifc_to_step"); + adacpp::tune_malloc_for_streaming(); auto idx = adacpp::step::StreamIndex::from_file(in_path); prof.phase("scan_index"); if (!idx.ok()) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index ca1ec8d..35ca455 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -58,6 +58,7 @@ #include "posix_compat.h" #include "mem_trim.h" +#include "mem_tune.h" #include "effective_concurrency.h" #include @@ -2928,6 +2929,7 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin using adacpp::ngeom::NgeomRoot; FileStats fs; adacpp::prof::StepProfiler prof("stream_step_to_ifc(par)"); + adacpp::tune_malloc_for_streaming(); auto idx = adacpp::step::StreamIndex::from_file(in_path); prof.phase("scan_index"); adacpp::step::Resolver master(idx); @@ -3116,6 +3118,7 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa using adacpp::step_emit::StepBrepEmitter; adacpp::ifc_emit::FileStats fs; adacpp::prof::StepProfiler prof("stream_step_to_step"); + adacpp::tune_malloc_for_streaming(); auto idx = adacpp::step::StreamIndex::from_file(in_path); prof.phase("scan_index"); adacpp::step::Resolver master(idx); diff --git a/src/cad/mem_tune.h b/src/cad/mem_tune.h new file mode 100644 index 0000000..fd84c8e --- /dev/null +++ b/src/cad/mem_tune.h @@ -0,0 +1,44 @@ +#pragma once +// One-time glibc malloc tuning for the multithreaded streaming CAD pipeline. +// +// The streaming tessellator churns large transient buffers per solid (the resolved B-rep + libtess2's +// per-face DCEL). By default glibc keeps such freed blocks in the arena free-lists instead of returning +// them to the OS, so process RSS tracks the high-water of retained-but-dead memory, not the live set. +// Lowering the mmap/trim threshold makes the LARGE transient blocks round-trip through mmap and get +// unmapped to the OS the instant they are freed, trimming peak RSS at negligible cost. +// +// Threshold choice (measured, crane STEP->GLB, 39.7 M tris, 3 workers; peak RSS / wall): +// glibc default 3009 MB / 157 s (baseline) +// M_*_THRESHOLD = 4 MB 2866 MB / 159 s <-- chosen: -143 MB for +1% time +// M_*_THRESHOLD = 128 KB ~2450 MB / 279 s (too aggressive: mmap/munmap per libtess2 alloc +// triples the heavy-solid tessellation time) +// M_ARENA_MAX = 2/4 no memory win at 4, -560 MB but +56% time at 2 (dropped: bad trade) +// The 4 MB knee returns the big freed buffers (whole-solid resolve + large DCELs) without forcing the +// many small libtess2 allocations through mmap — those stay fast in the arena. On tessellation-working- +// set-heavy models (few tris but complex faces, e.g. the 469826 STEP that OOMs) the reclaim is larger, +// since there the peak is transient DCEL churn rather than one solid's live mesh. +// +// Call ONCE at the top of each streaming entry point. glibc-only; a no-op on macOS / Windows / wasm. + +#include +#if defined(__GLIBC__) +#include +#endif + +namespace adacpp { +inline void tune_malloc_for_streaming() { +#if defined(__GLIBC__) + static bool done = false; // idempotent: mallopt is process-global, first call wins + if (done) + return; + done = true; + if (std::getenv("ADACPP_NO_MALLOC_TUNE")) // escape hatch / A-B switch: leave glibc defaults + return; + // Large freed blocks -> straight back to the OS on free (mmap) and trim the arena top; small + // allocations stay in-arena (fast). 4 MB is the measured knee — memory win with ~no time cost. + constexpr long kThreshold = 4L * 1024 * 1024; + mallopt(M_MMAP_THRESHOLD, (int) kThreshold); + mallopt(M_TRIM_THRESHOLD, (int) kThreshold); +#endif +} +} // namespace adacpp diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index d1c110b..918cfd2 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -23,6 +23,7 @@ #include #include "mem_trim.h" +#include "mem_tune.h" #include "effective_concurrency.h" #include "posix_compat.h" @@ -44,6 +45,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou double model_scale = 0.0) { using namespace adacpp::ngeom; adacpp::prof::StepProfiler prof("stream_step_to_glb"); + adacpp::tune_malloc_for_streaming(); // bound streaming peak RSS (mmap/trim tuning) before the pool // File-backed offset index: mmap to scan (freed-behind), then pread each statement on demand so // the file lives in the OS page cache, not process RSS. diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index 0f14d0c..0bda700 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -24,6 +24,7 @@ #include #include "mem_trim.h" +#include "mem_tune.h" #include "effective_concurrency.h" #include "posix_compat.h" @@ -161,6 +162,7 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o using namespace adacpp::ngeom; static const std::array kIdentity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; adacpp::prof::StepProfiler prof("stream_step_to_mesh"); + adacpp::tune_malloc_for_streaming(); // bound streaming peak RSS (mmap/trim tuning) before the pool adacpp::step::StreamIndex idx = adacpp::step::StreamIndex::from_file(in_path); if (!idx.ok()) From a2af3159794f5de35e3f769ac8d69fcd2846aba7 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 16:01:00 +0200 Subject: [PATCH 45/65] perf: B-spline-weighted LPT cost estimate + per-solid audit instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LPT scheduler ordered solids by raw face count, which predicts tessellation time only weakly (Spearman 0.487) — the real cost is B-spline uv-inversion, so a small-face solid with dense B-spline surfaces is dispatched late and pins a worker. Add solid_cost_estimate(): face count weighted by per-face surface complexity (a B-spline surface costs ~ its control-point grid nu*nv), sampled over up to 8 faces per solid and extrapolated. ~free (a handful of index derefs, no geometry built). Route all STEP LPT sites through it (GLB / mesh / IFC / STEP / parity writers). Crane STEP->GLB (39.7M tris): Spearman(estimate,ms) 0.487 -> 0.542, and peak RSS 2866 -> 1976 MB — the weighted estimate lifts the heavy B-spline solids (e.g. #300: est 11803 -> 963419) past the phase-A fair_share threshold, so they are resolved serially with the face-level pool instead of concurrently in phase B. That is the memory-aware routing the OOM cases need, from the existing huge-solid guard, at no wall-clock cost (159 -> 151 s). Instrumentation for continuous LPT tuning (profile-gated, zero cost in prod): solid_timed now records (id, est, faces, tris, ms) per solid; the cpp_profile JSON gains spearman_est_ms (does the estimate predict duration?) and spearman_est_tris (does it predict per-solid memory?), plus est/tris on each slowest_solids row — so every profiled audit run measures the cost model's accuracy without a local re-run. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 8 ++-- src/cad/step_to_glb_stream.h | 10 ++-- src/cad/step_to_mesh_stream.h | 2 +- src/cadit/step/step_reader.h | 81 ++++++++++++++++++++++++++++++++ src/geom/neutral/ngeom_profile.h | 56 ++++++++++++++++++---- 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 35ca455..65d7815 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -2940,12 +2940,12 @@ static adacpp::ifc_emit::FileStats write_ifc_file_parallel_impl(const std::strin std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); if (max_solids > 0 && (long) roots.size() > max_solids) roots.resize(max_solids); - // LPT order (one solid_face_count pass) — load-balance the heavy solids first. + // LPT order (one cost-estimate pass) — load-balance the heavy solids first. { std::vector> cost; cost.reserve(roots.size()); for (long sid : roots) - cost.emplace_back(master.solid_face_count(sid), sid); + cost.emplace_back(master.solid_cost_estimate(sid), sid); master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); for (size_t i = 0; i < cost.size(); ++i) @@ -3132,7 +3132,7 @@ static adacpp::ifc_emit::FileStats write_step_file_impl(const std::string &in_pa std::vector> cost; cost.reserve(roots.size()); for (long sid : roots) - cost.emplace_back(master.solid_face_count(sid), sid); + cost.emplace_back(master.solid_cost_estimate(sid), sid); master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); for (size_t i = 0; i < cost.size(); ++i) @@ -3342,7 +3342,7 @@ static void step_parity_impl(const std::string &in_path, double deflection, doub std::vector> cost; cost.reserve(roots.size()); for (long sid : roots) - cost.emplace_back(master.solid_face_count(sid), sid); + cost.emplace_back(master.solid_cost_estimate(sid), sid); master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); for (size_t i = 0; i < cost.size(); ++i) diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 918cfd2..83a92cc 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -75,6 +75,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou // while the other threads idle after 20 s). Those roots — a prefix of the sorted order — are // processed FIRST, one at a time, with tessellate_doc's face-level pool using every thread. std::vector roots(idx.lists.roots.begin(), idx.lists.roots.end()); + std::vector est_of_root; // LPT cost estimate per root (parallel to `roots`); empty if serial size_t n_huge = 0; if (nthreads > 1) { constexpr size_t HUGE_FACES = 2048; @@ -82,14 +83,17 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou cost.reserve(roots.size()); size_t total_faces = 0; for (long sid : roots) { - size_t fc = master.solid_face_count(sid); + size_t fc = master.solid_cost_estimate(sid); // face count weighted by B-spline complexity total_faces += fc; cost.emplace_back(fc, sid); } master.clear_geom_cache(); std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); - for (size_t i = 0; i < cost.size(); ++i) + est_of_root.resize(roots.size()); + for (size_t i = 0; i < cost.size(); ++i) { roots[i] = cost[i].second; + est_of_root[i] = cost[i].first; + } // Tail-dominance test, not absolute size: phase A serializes resolve between its // face-pool bursts, so routing merely-large solids through it SLOWS a well-balanced // file (crane: 7291 solids, many 2-4k faces, pool efficiency already 3.5x -> phase A @@ -143,7 +147,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou TessMesh tm = tessellate_doc(one, tpp); if (tprof && prof.timing()) prof.solid_timed( - roots[i], fc, + roots[i], i < est_of_root.size() ? est_of_root[i] : 0, fc, tm.indices.size() / 3, std::chrono::duration(std::chrono::steady_clock::now() - tt0).count()); if (tm.indices.empty()) return; diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index 0bda700..4f59423 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -191,7 +191,7 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o cost.reserve(roots.size()); size_t total_faces = 0; for (long sid : roots) { - size_t fc = master.solid_face_count(sid); + size_t fc = master.solid_cost_estimate(sid); // face count weighted by B-spline complexity total_faces += fc; cost.emplace_back(fc, sid); } diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index c37cf35..f05480c 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -825,6 +825,87 @@ class Resolver { return n; } + // Complexity weight of one surface for the LPT cost proxy. A B-spline surface costs ~ its + // control-point grid (nu*nv): that (not face count) is what drives tessellation time — uv-inversion + // + grid evaluation scale with the control net. Analytic surfaces (plane/cyl/cone/sphere/torus) are + // cheap -> weight 1. One index deref, no geometry built. + size_t surface_cost(long surf_id) { + const Instance *s = inst(surf_id); + if (!s) + return 1; + const Value *grid = nullptr; // the control_points_list (nested u-rows), when this is a B-spline + if (s->complex) { + if (const std::vector *bs = sub(s, "B_SPLINE_SURFACE")) + if (bs->size() > 2 && (*bs)[2].kind == Kind::List) + grid = &(*bs)[2]; + } else if (s->type == "B_SPLINE_SURFACE_WITH_KNOTS" && s->args.size() > 3 && + s->args[3].kind == Kind::List) { + grid = &s->args[3]; + } + if (!grid || grid->items.empty()) + return 1; // analytic surface + size_t nu = grid->items.size(); + size_t nv = grid->items[0].kind == Kind::List ? grid->items[0].items.size() : 1; + size_t cp = nu * nv; + return cp < 1 ? 1 : cp; + } + + // Surface-aware LPT cost proxy: face count weighted by per-face surface complexity. Face count + // alone predicts tessellation time only weakly (Spearman ~0.49) because the real cost is B-spline + // uv-inversion, so a small-face solid whose faces are dense B-splines (few faces, very slow) gets + // dispatched late and pins a worker at the tail. Sample up to kSample faces' surfaces, average + // their weight, and scale by the full face count. Still ~free: a handful of derefs per solid, no + // geometry built. Call clear_geom_cache() after the batch. + size_t solid_cost_estimate(long sid) { + size_t faces = solid_face_count(sid); + if (faces == 0) + return 0; + constexpr size_t kSample = 8; + size_t sampled = 0, weight = 0; + std::function sample_shell = [&](long shell_id) { + if (sampled >= kSample) + return; + const Instance *sh = inst(shell_id); + if (!sh) + return; + if (sh->type == "ORIENTED_CLOSED_SHELL") { + if (sh->args.size() > 2 && sh->args[2].is_ref()) + sample_shell(sh->args[2].i); + return; + } + if (sh->args.size() > 1 && sh->args[1].kind == Kind::List) { + for (const Value &f : sh->args[1].items) { + if (sampled >= kSample) + break; + ++sampled; + const Instance *face = f.is_ref() ? inst(f.i) : nullptr; + if (face && (face->type == "ADVANCED_FACE" || face->type == "FACE_SURFACE") && + face->args.size() > 2 && face->args[2].is_ref()) + weight += surface_cost(face->args[2].i); + else + weight += 1; + } + } + }; + const Instance *in = inst(sid); + if (!in || in->args.size() < 2) + return faces; + if (in->type == "MANIFOLD_SOLID_BREP" || in->type == "BREP_WITH_VOIDS") { + if (in->args[1].is_ref()) + sample_shell(in->args[1].i); + } else if (in->args[1].kind == Kind::List) { + for (const Value &sh : in->args[1].items) { + if (sampled >= kSample) + break; + if (sh.is_ref()) + sample_shell(sh.i); + } + } + if (sampled == 0) + return faces; + return faces * weight / sampled; // extrapolate the sampled average weight to all faces + } + double unit_scale() const { return unit_scale_; } diff --git a/src/geom/neutral/ngeom_profile.h b/src/geom/neutral/ngeom_profile.h index db89cc5..3141374 100644 --- a/src/geom/neutral/ngeom_profile.h +++ b/src/geom/neutral/ngeom_profile.h @@ -151,14 +151,16 @@ class StepProfiler { bool timing() const { return timing_; } - // Record one solid's (id, face count = the LPT proxy, actual tessellation ms) so the destructor - // can report whether the face-count proxy actually predicts tessellation time. Env-gated - // separately (ADACPP_STEP_SOLID_TIMING) since it keeps a row per solid. - void solid_timed(long id, size_t faces, double ms) { + // Record one solid's (id, LPT cost estimate, resolved face count, output tris, actual tessellation + // ms) so the destructor / audit JSON can report whether the estimate predicts tessellation time + // (spearman_est_ms) AND memory (spearman_est_tris — tris is the per-solid tessellation-memory + // proxy). This is the continuous-tuning signal for the LPT cost model. Env-gated separately + // (ADACPP_STEP_SOLID_TIMING) since it keeps a row per solid. + void solid_timed(long id, size_t est, size_t faces, size_t tris, double ms) { if (!timing_) return; std::lock_guard lk(mu_); - timed_.push_back({id, faces, ms}); + timed_.push_back({id, est, faces, tris, ms}); } ~StepProfiler() { @@ -270,11 +272,22 @@ class StepProfiler { break; if (emitted++) rows += ","; - rows += "{\"id\":" + std::to_string(ts.id) + ",\"faces\":" + std::to_string(ts.faces) + + rows += "{\"id\":" + std::to_string(ts.id) + ",\"est\":" + std::to_string(ts.est) + + ",\"faces\":" + std::to_string(ts.faces) + ",\"tris\":" + std::to_string(ts.tris) + ",\"ms\":" + fmt_num(ts.ms) + "}"; } if (!rows.empty()) j += ",\"slowest_solids\":[" + rows + "]"; + // LPT accuracy signals for continuous tuning: does the cost estimate rank-predict the + // actual tessellation time and the per-solid memory proxy (tris)? + j += ",\"spearman_est_ms\":" + + fmt_num(spearman([](const SolidTime &s) { return (double) s.est; }, + [](const SolidTime &s) { return s.ms; }), + 3); + j += ",\"spearman_est_tris\":" + + fmt_num(spearman([](const SolidTime &s) { return (double) s.est; }, + [](const SolidTime &s) { return (double) s.tris; }), + 3); } j += "}"; std::fprintf(stderr, "[STEPPROF-JSON] %s\n", j.c_str()); @@ -301,9 +314,36 @@ class StepProfiler { }; struct SolidTime { long id; - size_t faces; - double ms; + size_t est; // LPT cost estimate (the value being validated) + size_t faces; // resolved face count + size_t tris; // output triangles — per-solid tessellation-memory proxy + double ms; // actual tessellation time }; + + // Spearman rank correlation between two per-solid signals over `timed_` (1.0 = perfect rank + // agreement). `pick` maps a SolidTime to the value on one axis. Used to score how well the LPT + // estimate predicts duration (ms) and memory (tris) so the audit can track the model over time. + template double spearman(FA a, FB b) const { + size_t n = timed_.size(); + if (n < 2) + return 1.0; + std::vector oa(n), ob(n); + for (size_t i = 0; i < n; ++i) + oa[i] = ob[i] = i; + std::sort(oa.begin(), oa.end(), [&](size_t x, size_t y) { return a(timed_[x]) > a(timed_[y]); }); + std::sort(ob.begin(), ob.end(), [&](size_t x, size_t y) { return b(timed_[x]) > b(timed_[y]); }); + std::vector ra(n), rb(n); + for (size_t r = 0; r < n; ++r) { + ra[oa[r]] = r; + rb[ob[r]] = r; + } + double d2 = 0; + for (size_t i = 0; i < n; ++i) { + double d = (double) ra[i] - (double) rb[i]; + d2 += d * d; + } + return 1.0 - 6.0 * d2 / ((double) n * ((double) n * n - 1.0)); + } struct Thr { int tid; double busy_ms; From d7c2748c4b454f335848ca3b729f2922b675cfbc Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 16:08:52 +0200 Subject: [PATCH 46/65] feat: parallelize native IFC->GLB (LPT-ordered, per-thread resolvers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream_ifc_to_glb was single-threaded because IfcResolver built its colour / spatial-hierarchy maps lazily per instance. Add build_metadata() (build those maps once on a master) + copy_metadata_from() (share the read-only maps into per-thread worker resolvers; the statement/surface caches + pread scratch stay per-thread, and the const StreamIndex is pread-safe) so the products can be tessellated across threads — the same phase-A huge-prefix + dynamic phase-B pool as stream_step_to_glb. LPT ordering uses a new product_cost(pid): the body representation's brep face count reached by a few index derefs (product -> IfcProductDefinitionShape -> IfcShapeRepresentation -> Items -> brep -> shell), procedural items a small constant. num_threads (0 = cgroup-aware effective_concurrency) plumbed through the binding; the adapy caller passes the same worker count as the STEP path. Parallel (3 threads) matches serial product counts on all 12 local IFC fixtures (brep, CSG, half-space, swept, surface-model, 35-product assembly); single-product GLBs are byte-identical, multi-product differ only in merge-lane byte layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 16 ++--- src/cad/ifc_reader.h | 66 ++++++++++++++++++++ src/cad/ifc_to_glb_stream.h | 119 ++++++++++++++++++++++++++++++------ 3 files changed, 176 insertions(+), 25 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 65d7815..6e2c913 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -743,12 +743,13 @@ int stream_step_to_glb_impl(const std::string &in_path, const std::string &out_p /*spill_dir=*/"", model_scale); } -// Native OCC-free IFC -> GLB (IfcResolver: geometry + colour + spatial tree, baked to metres). v1 -// single-threaded. Returns the number of products written, or -1 on error. +// Native OCC-free IFC -> GLB (IfcResolver: geometry + colour + spatial tree, baked to metres). +// Parallel: LPT-ordered products across `num_threads` workers (0 = cgroup-aware auto). Returns the +// number of products written, or -1 on error. int stream_ifc_to_glb_impl(const std::string &in_path, const std::string &out_path, double deflection, - double angular_deg, bool meshopt, double model_scale) { + double angular_deg, bool meshopt, double model_scale, int num_threads) { return (int) adacpp::stream_ifc_to_glb(in_path, out_path, deflection, angular_deg, meshopt, - /*spill_dir=*/"", model_scale); + /*spill_dir=*/"", model_scale, num_threads); } // Threaded OCC-free STEP -> STL / OBJ (same reader + parallel tessellation as the GLB core, but bakes @@ -3801,11 +3802,12 @@ void cad_module(nb::module_ &m) { "solids written (-1 on I/O error). angular_deg in degrees."); m.def("stream_ifc_to_glb", &stream_ifc_to_glb_impl, "in_path"_a, "out_path"_a, "deflection"_a = 0.0, - "angular_deg"_a = 20.0, "meshopt"_a = true, "model_scale"_a = 0.0, + "angular_deg"_a = 20.0, "meshopt"_a = true, "model_scale"_a = 0.0, "num_threads"_a = 0, "Native IFC -> GLB file (no ifcopenshell, no OCC): IfcResolver resolves each product's " "geometry + presentation colour + spatial-structure path, libtess2 tessellates, baked to " - "metres into a merge-by-colour GLB matching the viewer. Single-threaded v1; curve-only " - "bodies (alignment axes) skipped. Returns products written (-1 on error)."); + "metres into a merge-by-colour GLB matching the viewer. LPT-ordered across num_threads " + "workers (0=cgroup-aware auto); curve-only bodies (alignment axes) skipped. Returns products " + "written (-1 on error)."); m.def("stream_step_to_mesh", &stream_step_to_mesh_impl, "in_path"_a, "out_path"_a, "fmt"_a, "deflection"_a = 2.0, "angular_deg"_a = 20.0, "num_threads"_a = 0, "model_scale"_a = 0.0, diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 40e52f5..1f5dacc 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -463,7 +463,73 @@ class IfcResolver { surf_cache_.clear(); } + // Build the expensive, read-only, cross-product metadata (colour + spatial-hierarchy maps) ONCE on + // a master resolver so parallel workers can share it via copy_metadata_from instead of each + // rebuilding it (both scan every entity in the file). clear_cache() must not wipe these. + void build_metadata() { + build_colour_map(); + build_rel_maps(); + } + // Share the master's read-only metadata into this (worker) resolver. The per-product transient + // state (statement/surface caches, pread scratch, solid_src_/mixed_) stays per-thread; only idx_ + // (const, pread-safe) and these maps are shared, so each worker resolves independently. + void copy_metadata_from(const IfcResolver &m) { + colour_map_ = m.colour_map_; + colour_map_built_ = m.colour_map_built_; + contained_of_ = m.contained_of_; + parent_of_ = m.parent_of_; + rel_maps_built_ = m.rel_maps_built_; + angle_scale_ = m.angle_scale_; + } + + // Cheap LPT cost proxy for a product: the face count of its body representation's brep items + // (a few index derefs down product -> IfcProductDefinitionShape -> IfcShapeRepresentation -> + // Items -> brep -> shell, no geometry built). Procedural items (swept/CSG/mapped) get a small + // constant — they rarely dominate an IFC tessellation the way a dense brep does. Call + // clear_cache() after the batch. + size_t product_cost(long pid) { + const Instance *p = inst(pid); + if (!p) + return 1; + const Instance *pds = inst(ref_arg(*p, 6)); + if (!pds || pds->args.size() < 3 || !pds->args[2].is_list()) + return 1; + size_t cost = 0; + for (const Value &srref : pds->args[2].items) { + const Instance *sr = inst(srref.i); + if (!sr || sr->args.size() < 4 || !sr->args[3].is_list()) + continue; + std::string_view id = + (sr->args.size() > 1 && sr->args[1].kind == adacpp::step::Kind::Str) ? sr->args[1].s : std::string_view{}; + if (id == "FootPrint" || id == "Annotation" || id == "Profile" || id == "Plan" || id == "Box" || id == "Axis") + continue; // 2D / reference reps are not tessellated + for (const Value &itref : sr->args[3].items) + if (const Instance *it = inst(itref.i)) + cost += item_face_count(it); + } + return cost ? cost : 1; + } + private: + // Face count of one representation item (brep -> shell face list; procedural -> small constant). + size_t item_face_count(const Instance *it) { + std::string_view t = it->type; + if (iequals(t, "IFCADVANCEDBREP") || iequals(t, "IFCFACETEDBREP")) { + const Instance *sh = inst(ref_arg(*it, 0)); + return (sh && !sh->args.empty() && sh->args[0].is_list()) ? sh->args[0].items.size() : 1; + } + if (iequals(t, "IFCSHELLBASEDSURFACEMODEL") || iequals(t, "IFCFACEBASEDSURFACEMODEL")) { + size_t n = 0; + if (!it->args.empty() && it->args[0].is_list()) + for (const Value &sh : it->args[0].items) + if (const Instance *s = inst(sh.i)) + if (!s->args.empty() && s->args[0].is_list()) + n += s->args[0].items.size(); + return n ? n : 1; + } + return 4; // procedural (extrusion / revolve / sweep / CSG / mapped) — cheap-ish default + } + const StreamIndex &idx_; std::unordered_map> cache_; std::unordered_map> surf_cache_; diff --git a/src/cad/ifc_to_glb_stream.h b/src/cad/ifc_to_glb_stream.h index e4a5891..70db8be 100644 --- a/src/cad/ifc_to_glb_stream.h +++ b/src/cad/ifc_to_glb_stream.h @@ -6,16 +6,26 @@ // on selection from the source model). // // Mirrors stream_step_to_glb but drives IfcResolver (proxy_roots / resolve_product / unit_scale) -// instead of the STEP Resolver. Single-threaded v1: IfcResolver's colour/rel maps are lazily built -// per instance, so parallelising needs a copy_metadata_from-style share (follow-up). Curve-only -// bodies (alignment axes -> GL_LINES) are skipped here (GlbSolid is triangles-only). +// instead of the STEP Resolver. Parallel: a master resolver builds the shared read-only metadata +// (colour + spatial maps) once, LPT-orders products by product_cost, and per-thread worker resolvers +// (copy_metadata_from) tessellate into per-thread lanes — same phase-A huge-prefix + dynamic phase-B +// pool as the STEP core. Curve-only bodies (alignment axes -> GL_LINES) are skipped (GlbSolid is +// triangles-only). #pragma once +#include #include +#include +#include #include #include +#include #include +#include "effective_concurrency.h" +#include "mem_trim.h" +#include "mem_tune.h" + #include "../geom/neutral/ada_ext_schema.h" #include "../geom/neutral/ngeom_glb.h" #include "../geom/neutral/ngeom_tessellate.h" @@ -26,20 +36,49 @@ namespace adacpp { inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out_path, double deflection, double angular_deg, bool meshopt, const std::string &spill_dir = "", - double model_scale = 0.0) { + double model_scale = 0.0, int num_threads = 0) { using namespace adacpp::ngeom; + adacpp::tune_malloc_for_streaming(); adacpp::step::StreamIndex idx = adacpp::step::StreamIndex::from_file(in_path); if (!idx.ok()) return -1; - adacpp::ifc_read::IfcResolver r(idx); - std::vector roots = r.proxy_roots(); + // Master resolver: build the expensive read-only metadata once (workers share it, never rebuild). + adacpp::ifc_read::IfcResolver master(idx); + master.build_metadata(); + std::vector roots = master.proxy_roots(); + const double usc = master.unit_scale(); // metres per file length-unit -> bake to metres TessParams tp; tp.deflection = deflection; tp.max_angle = angular_deg * PI / 180.0; tp.threads = 1; tp.model_scale = model_scale; - const double usc = r.unit_scale(); // metres per file length-unit -> bake to metres (viewer default) + + int nthreads = num_threads > 0 ? num_threads : (int) adacpp::effective_concurrency(); + + // LPT order (heaviest products first) + huge-prefix detection, mirroring stream_step_to_glb: a + // product bigger than a thread's fair share of the whole file is resolved serially with the + // face-level pool so it can't pin one worker (and its resolve+tess memory doesn't stack with the + // other lanes'). + size_t n_huge = 0; + if (nthreads > 1 && roots.size() > 1) { + constexpr size_t HUGE_FACES = 2048; + std::vector> cost; + cost.reserve(roots.size()); + size_t total = 0; + for (long pid : roots) { + size_t c = master.product_cost(pid); + total += c; + cost.emplace_back(c, pid); + } + master.clear_cache(); + std::sort(cost.begin(), cost.end(), [](const auto &a, const auto &b) { return a.first > b.first; }); + for (size_t i = 0; i < cost.size(); ++i) + roots[i] = cost[i].second; + const size_t fair_share = total / (size_t) nthreads; + while (n_huge < cost.size() && cost[n_huge].first >= HUGE_FACES && cost[n_huge].first >= fair_share) + ++n_huge; + } // Spill dir: private mkdtemp (auto-removed) unless the caller supplied one. std::string spill; @@ -59,20 +98,27 @@ inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out if (spill.empty()) return -1; - long nwritten = 0; + std::atomic nwritten{0}; bool ok = false; - { // lane scoped so its temp files are gone before the (maybe) rmdir - adacpp::glb::GlbSpillWriter lane(spill, 0); - for (long pid : roots) { - NgeomRoot root = r.resolve_product(pid); + { // lanes scoped so their temp files are gone before the (maybe) rmdir + std::deque lanes; + for (int t = 0; t < nthreads; ++t) + lanes.emplace_back(spill, t); + std::atomic next{n_huge}; + + // Resolve one product with the caller's resolver, tessellate (tpp.threads>1 => face pool), + // bake to metres + spill. Shared by the huge-prefix phase and the worker pool. + auto process = [&](adacpp::ifc_read::IfcResolver &r, adacpp::glb::GlbSpillWriter &lane, size_t i, + const TessParams &tpp) { + NgeomRoot root = r.resolve_product(roots[i]); r.clear_cache(); // bounded memory: statement/surface caches don't grow across products NgeomDoc one; one.roots.push_back(std::move(root)); - TessMesh tm = tessellate_doc(one, tp); + TessMesh tm = tessellate_doc(one, tpp); if (tm.indices.empty()) - continue; + return; if (tm.mesh_type == MeshType::LINES) - continue; // curve-only body (alignment axis); GlbSolid is triangles-only in v1 + return; // curve-only body (alignment axis); GlbSolid is triangles-only const NgeomRoot &rr = one.roots[0]; adacpp::glb::GlbSolid gs; gs.positions = std::move(tm.positions); @@ -94,15 +140,52 @@ inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out } } lane.add(gs); - ++nwritten; + nwritten.fetch_add(1, std::memory_order_relaxed); + }; + + // Phase A — the huge prefix, one product at a time with every thread on its faces. + if (n_huge > 0) { + TessParams tph = tp; + tph.threads = nthreads; + adacpp::ifc_read::IfcResolver r0(idx); + r0.copy_metadata_from(master); + for (size_t i = 0; i < n_huge; ++i) + process(r0, lanes[0], i, tph); + adacpp::mem_trim(); } - std::vector lane_ptrs{&lane}; + // Phase B — each worker pulls remaining products off the shared counter, resolving with its OWN + // caches + a copy of the shared metadata, into its lane. + auto worker = [&](int t) { + adacpp::ifc_read::IfcResolver r(idx); + r.copy_metadata_from(master); + adacpp::glb::GlbSpillWriter &lane = lanes[t]; + int local = 0; + for (;;) { + size_t i = next.fetch_add(1, std::memory_order_relaxed); + if (i >= roots.size()) + break; + process(r, lane, i, tp); + if (++local % 128 == 0) + adacpp::mem_trim(); + } + }; + std::vector pool; + pool.reserve(nthreads - 1); + for (int t = 1; t < nthreads; ++t) + pool.emplace_back(worker, t); + worker(0); + for (std::thread &th : pool) + th.join(); + + std::vector lane_ptrs; + for (adacpp::glb::GlbSpillWriter &l : lanes) + lane_ptrs.push_back(&l); const std::string ada_ext = adacpp::ada_ext::AdaDesignAndAnalysisExtension{}.to_json(); ok = adacpp::glb::write_glb_merged(out_path, lane_ptrs, ada_ext, meshopt); } if (remove_after) ::rmdir(spill.c_str()); - return ok ? nwritten : -1; + return ok ? nwritten.load() : -1; } } // namespace adacpp From 5a7100a81eb283127d67dda470fce17631b6deb3 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 17:11:00 +0200 Subject: [PATCH 47/65] fix: smooth-shade explicit IFC face-sets (IfcFacetedBrep / face-sets) The native GLB carries no normals, so the viewer shades from the welded geometry (vertices shared within the weld's crease angle). Analytic surfaces tessellate finely (small facet angles -> shared -> smooth), but an explicit faceted representation of a curved surface (IfcFacetedBrep / IfcPolygonalFaceSet / IfcTriangulatedFaceSet, e.g. the basin samples) has coarse facets that exceed the default 40 deg crease -> split into hard edges -> it renders faceted next to the smooth IfcAdvancedBrep of the same shape. Flag those roots in the IFC reader (SolidItemN.smooth_faceset -> NgeomRoot) and weld them with a relaxed 80 deg crease so gentle facets shade smoothly while genuine ~90 deg edges (box corners, a basin rim) stay sharp. AdvancedBrep and analytic geometry keep the 40 deg default. Wiring verified: at a 179 deg crease the box-tessellation fixtures collapse from 20 -> 8 verts (all corners weld), confirming the flag reaches weld_mesh; at 80 deg boxes stay at 20 (corners sharp). 12 local IFC fixtures convert unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 4 ++++ src/geom/neutral/ngeom_tessellate.cpp | 12 ++++++++---- src/geom/neutral/ngeom_topology.h | 8 ++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 1f5dacc..363411c 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -1421,6 +1421,7 @@ class IfcResolver { } else if (iequals(t, "IFCBOOLEANRESULT") || iequals(t, "IFCBOOLEANCLIPPINGRESULT")) { out.boolean = mk_boolean(in); } else if (iequals(t, "IFCADVANCEDBREP") || iequals(t, "IFCFACETEDBREP")) { + out.smooth_faceset = out.smooth_faceset || iequals(t, "IFCFACETEDBREP"); // planar facets const Instance *shell = inst(ref_arg(*in, 0)); if (shell && !shell->args.empty() && shell->args[0].is_list()) for (const Value &fref : shell->args[0].items) @@ -1432,6 +1433,7 @@ class IfcResolver { out.extrusion = ex; } else if (iequals(t, "IFCPOLYGONALFACESET")) { // (Coordinates=IfcCartesianPointList3D, Closed, Faces=IfcIndexedPolygonalFace[], PnIndex) + out.smooth_faceset = true; // planar facets approximating a (usually curved) surface std::vector pts = point_list_3d(ref_arg(*in, 0)); if (in->args.size() > 2 && in->args[2].is_list()) for (const Value &fref : in->args[2].items) @@ -1447,6 +1449,7 @@ class IfcResolver { } } else if (iequals(t, "IFCTRIANGULATEDFACESET")) { // (Coordinates, Normals, Closed, CoordIndex=list of (i,j,k) triples, PnIndex) + out.smooth_faceset = true; // triangulated approximation of a (usually curved) surface std::vector pts = point_list_3d(ref_arg(*in, 0)); if (in->args.size() > 3 && in->args[3].is_list()) for (const Value &tri : in->args[3].items) @@ -2074,6 +2077,7 @@ class IfcResolver { SolidItemN it = resolve_solid_item(id); if (!solid_ok(it)) return; // unsupported geometry -> product yields nothing here -> OCC fallback + root.smooth_faceset = root.smooth_faceset || it.smooth_faceset; // relaxed weld crease for facets if (!it.faces.empty() && !it.extrusion && !it.revolve && !it.boolean) { if (root.extrusion || root.revolve || root.boolean) { // brep mixed with a procedural solid mixed_ = true; diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index f6dfad9..2991203 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1926,16 +1926,20 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { // Weld each ROOT independently (a shared index buffer + crease-angle smooth normals), then append // — per-root keeps group boundaries + picking intact (never merges verts across solids). Skips // LINES (curve bodies). tp.weld=false leaves the raw flat-shaded soup. - auto weld_root = [&](TessMesh &rm) { + // Explicit faceted representations (IfcFacetedBrep / face-sets) approximate curved surfaces with + // planar facets; weld them with a relaxed crease so gentle facets shade smoothly (still keeping + // genuine ~90° edges sharp). Analytic geometry keeps the default 40°. + constexpr double kFacesetCreaseDeg = 80.0; + auto weld_root = [&](TessMesh &rm, bool smooth_faceset) { if (tp.weld && rm.mesh_type == MeshType::TRIANGLES && !rm.indices.empty()) - weld_mesh(rm.positions, rm.indices, rm.normals); + weld_mesh(rm.positions, rm.indices, rm.normals, smooth_faceset ? kFacesetCreaseDeg : 40.0); }; unsigned want = tp.threads > 1 ? (unsigned) tp.threads : 1u; if (want <= 1 || roots.size() < 2) { for (const NgeomRoot &root : roots) { TessMesh rm; tessellate_one_root(root, tp, rm); - weld_root(rm); + weld_root(rm, root.smooth_faceset); uint32_t first = (uint32_t) mesh.indices.size(); uint32_t vfirst = (uint32_t) (mesh.positions.size() / 3); mesh.mesh_type = rm.mesh_type; // single-root streaming: propagate LINES/TRIANGLES @@ -1956,7 +1960,7 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { pool.emplace_back([&]() { for (size_t i = next.fetch_add(1); i < roots.size(); i = next.fetch_add(1)) { tessellate_one_root(roots[i], tp1, locals[i]); - weld_root(locals[i]); + weld_root(locals[i], roots[i].smooth_faceset); } }); for (std::thread &th : pool) diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 6bd47eb..640aca9 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -353,6 +353,7 @@ struct SolidItemN { std::shared_ptr sweep; std::shared_ptr boolean; std::vector> faces; // shell operand + bool smooth_faceset = false; // explicit faceted rep -> relaxed weld crease }; // A CSG boolean: op(a, b). op 0=difference (cut), 1=union (fuse), 2=intersection (common). @@ -383,6 +384,13 @@ struct NgeomRoot { // reference kernel also refuses). Distinguishes "intentionally empty" from "unsupported": the // stream must NOT count these as products_skipped (they'd else drive a pointless OCC fallback). bool recognized_empty = false; + // This root is an EXPLICIT faceted representation (IfcFacetedBrep / Polygonal- or + // TriangulatedFaceSet): a curved surface approximated by many small planar facets. The GLB carries + // no normals, so the viewer shades from the welded geometry — but the default 40° weld crease + // splits coarse curved facets into hard edges, so a bowl looks faceted next to the smooth + // AdvancedBrep. Weld these roots with a relaxed crease so gentle facets shade smoothly while + // genuine ~90° edges (box corners, a basin rim) stay sharp. Set by the IFC reader. + bool smooth_faceset = false; // Presentation colour (STYLED_ITEM -> COLOUR_RGB), populated by the native STEP reader; the // NGEOM byte decoder leaves has_color=false (colour travels out-of-band on that path). bool has_color = false; From 46fe31802db79fb47d2e93102ce2802f7a26de28 Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 18:13:16 +0200 Subject: [PATCH 48/65] fix: explicit IFC face-sets were flattened to z=0 (fit the real plane up front) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit face-set / plain IfcFace (IfcFacetedBrep, IfcPolygonal- and IfcTriangulatedFaceSet, IfcShell/FaceBasedSurfaceModel) is built with a PLACEHOLDER identity plane (PlaneSurface{Frame{}}) and a 3D boundary polygon; the real plane was only meant to be re-fit by the tessellator. But that re-fit ran ONLY as an on-failure fallback (tessellate_face_impl) — and a 3D polygon projects perfectly validly onto the z=0 placeholder plane, so face_to_mesh SUCCEEDS and the fallback never fires. Result: the whole face-set collapses to z=0 (the basin-faceted-brep / basin-tessellation samples rendered as a flat sheet instead of a bowl). Flag placeholder-plane faces (FaceSurfaceN.fit_plane_from_loop, set by the IFC reader for plain IfcFace + the polygonal/triangulated face-set polys) and fit the plane from the 3D loop UP FRONT, before tessellating. 2D profile faces (extrusion SweptArea, swept-disk) are unflagged and keep the z=0 placeholder, placed later by their solid's frame — unchanged. basin-faceted-brep / basin-tessellation now recover the correct Z extent (0.094 m, matching the AdvancedBrep) instead of [0,0]. 12 local IFC fixtures (incl. surface-model, box_rotated) convert unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 9 +++++++-- src/geom/neutral/ngeom_tessellate.cpp | 16 ++++++++++++++-- src/geom/neutral/ngeom_topology.h | 4 ++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 363411c..41bf868 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -856,6 +856,7 @@ class IfcResolver { } else { fc->surface = std::make_shared(Frame{}); fc->same_sense = true; + fc->fit_plane_from_loop = true; // plain IfcFace: fit the real 3D plane from the boundary } if (in->args.empty() || !in->args[0].is_list()) return nullptr; @@ -1444,8 +1445,10 @@ class IfcResolver { std::vector poly; for (const Value &iv : pf->args[0].items) push_pt(poly, pts, iv); - if (auto f = make_profile(poly)) + if (auto f = make_profile(poly)) { + f->fit_plane_from_loop = true; // 3D face: fit its real plane, not z=0 out.faces.push_back(f); + } } } else if (iequals(t, "IFCTRIANGULATEDFACESET")) { // (Coordinates, Normals, Closed, CoordIndex=list of (i,j,k) triples, PnIndex) @@ -1458,8 +1461,10 @@ class IfcResolver { for (const Value &iv : tri.items) push_pt(poly, pts, iv); if (poly.size() >= 3) - if (auto f = make_profile(poly)) + if (auto f = make_profile(poly)) { + f->fit_plane_from_loop = true; // 3D triangle: fit its real plane out.faces.push_back(f); + } } } else if (iequals(t, "IFCSHELLBASEDSURFACEMODEL")) { // (SbsmBoundary = shells) if (!in->args.empty() && in->args[0].is_list()) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 2991203..511633d 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1433,8 +1433,20 @@ bool tessellate_face_impl(const FaceSurfaceN &face, const TessParams &tp, TessMe return false; } + // Placeholder-plane face (plain IfcFace / explicit face-set polygon): the declared surface is the + // z=0 identity plane, so fit the REAL plane from the 3D loop up front. Without this the 3D poly + // projects validly onto z=0 (face_to_mesh succeeds, so the on-failure re-fit below never runs) and + // the whole face-set collapses flat. + std::shared_ptr refit; + if (face.fit_plane_from_loop) { + refit = fit_plane(loops3d); + if (refit) + same_sense = true; // fitted normal follows the loop winding + } + const Surface &use_surf = refit ? *refit : surf; + auto cp = mesh.checkpoint(); - const char *reason = face_to_mesh(surf, loops3d, tp, same_sense, mesh); + const char *reason = face_to_mesh(use_surf, loops3d, tp, same_sense, mesh); if (!reason) return true; @@ -1452,7 +1464,7 @@ bool tessellate_face_impl(const FaceSurfaceN &face, const TessParams &tp, TessMe if (fl.size() != loops3d.size()) continue; auto cp2 = mesh.checkpoint(); - if (!face_to_mesh(surf, fl, fine, same_sense, mesh)) + if (!face_to_mesh(use_surf, fl, fine, same_sense, mesh)) return true; mesh.rollback(cp2); } diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 640aca9..a083fce 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -305,6 +305,10 @@ struct FaceSurfaceN { std::shared_ptr surface; bool same_sense = true; // false => face normal is the surface normal flipped std::vector bounds; // bounds[0] outer, rest holes + // The `surface` is a PLACEHOLDER identity plane (a plain IfcFace / explicit face-set polygon whose + // real 3D plane is only implied by its boundary points). The tessellator must fit the plane from + // the 3D loop up front — otherwise the poly projects flat onto the z=0 placeholder plane. + bool fit_plane_from_loop = false; }; struct ConnectedFaceSetN { From f502ab5d2a4158fbbd64892839a528f752184f0a Mon Sep 17 00:00:00 2001 From: krande Date: Fri, 10 Jul 2026 18:29:00 +0200 Subject: [PATCH 49/65] revert: drop the smooth_faceset weld-crease change (5a7100a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5a7100a relaxed the weld crease to 80° for explicit face-sets on the theory that they rendered faceted due to crease-split normals. That diagnosis was wrong: the face-sets were geometrically FLATTENED to z=0 (fixed in 46fe318 by fitting the real plane). With the geometry corrected the crease change is vestigial, so remove it and its NgeomRoot/SolidItemN.smooth_faceset plumbing — restoring the default 40° weld everywhere. The 46fe318 plane fit is untouched (basin face-sets keep their recovered Z extent). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 4 ---- src/geom/neutral/ngeom_tessellate.cpp | 12 ++++-------- src/geom/neutral/ngeom_topology.h | 8 -------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 41bf868..bd2ca0e 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -1422,7 +1422,6 @@ class IfcResolver { } else if (iequals(t, "IFCBOOLEANRESULT") || iequals(t, "IFCBOOLEANCLIPPINGRESULT")) { out.boolean = mk_boolean(in); } else if (iequals(t, "IFCADVANCEDBREP") || iequals(t, "IFCFACETEDBREP")) { - out.smooth_faceset = out.smooth_faceset || iequals(t, "IFCFACETEDBREP"); // planar facets const Instance *shell = inst(ref_arg(*in, 0)); if (shell && !shell->args.empty() && shell->args[0].is_list()) for (const Value &fref : shell->args[0].items) @@ -1434,7 +1433,6 @@ class IfcResolver { out.extrusion = ex; } else if (iequals(t, "IFCPOLYGONALFACESET")) { // (Coordinates=IfcCartesianPointList3D, Closed, Faces=IfcIndexedPolygonalFace[], PnIndex) - out.smooth_faceset = true; // planar facets approximating a (usually curved) surface std::vector pts = point_list_3d(ref_arg(*in, 0)); if (in->args.size() > 2 && in->args[2].is_list()) for (const Value &fref : in->args[2].items) @@ -1452,7 +1450,6 @@ class IfcResolver { } } else if (iequals(t, "IFCTRIANGULATEDFACESET")) { // (Coordinates, Normals, Closed, CoordIndex=list of (i,j,k) triples, PnIndex) - out.smooth_faceset = true; // triangulated approximation of a (usually curved) surface std::vector pts = point_list_3d(ref_arg(*in, 0)); if (in->args.size() > 3 && in->args[3].is_list()) for (const Value &tri : in->args[3].items) @@ -2082,7 +2079,6 @@ class IfcResolver { SolidItemN it = resolve_solid_item(id); if (!solid_ok(it)) return; // unsupported geometry -> product yields nothing here -> OCC fallback - root.smooth_faceset = root.smooth_faceset || it.smooth_faceset; // relaxed weld crease for facets if (!it.faces.empty() && !it.extrusion && !it.revolve && !it.boolean) { if (root.extrusion || root.revolve || root.boolean) { // brep mixed with a procedural solid mixed_ = true; diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 511633d..4867f46 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1938,20 +1938,16 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { // Weld each ROOT independently (a shared index buffer + crease-angle smooth normals), then append // — per-root keeps group boundaries + picking intact (never merges verts across solids). Skips // LINES (curve bodies). tp.weld=false leaves the raw flat-shaded soup. - // Explicit faceted representations (IfcFacetedBrep / face-sets) approximate curved surfaces with - // planar facets; weld them with a relaxed crease so gentle facets shade smoothly (still keeping - // genuine ~90° edges sharp). Analytic geometry keeps the default 40°. - constexpr double kFacesetCreaseDeg = 80.0; - auto weld_root = [&](TessMesh &rm, bool smooth_faceset) { + auto weld_root = [&](TessMesh &rm) { if (tp.weld && rm.mesh_type == MeshType::TRIANGLES && !rm.indices.empty()) - weld_mesh(rm.positions, rm.indices, rm.normals, smooth_faceset ? kFacesetCreaseDeg : 40.0); + weld_mesh(rm.positions, rm.indices, rm.normals); }; unsigned want = tp.threads > 1 ? (unsigned) tp.threads : 1u; if (want <= 1 || roots.size() < 2) { for (const NgeomRoot &root : roots) { TessMesh rm; tessellate_one_root(root, tp, rm); - weld_root(rm, root.smooth_faceset); + weld_root(rm); uint32_t first = (uint32_t) mesh.indices.size(); uint32_t vfirst = (uint32_t) (mesh.positions.size() / 3); mesh.mesh_type = rm.mesh_type; // single-root streaming: propagate LINES/TRIANGLES @@ -1972,7 +1968,7 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { pool.emplace_back([&]() { for (size_t i = next.fetch_add(1); i < roots.size(); i = next.fetch_add(1)) { tessellate_one_root(roots[i], tp1, locals[i]); - weld_root(locals[i], roots[i].smooth_faceset); + weld_root(locals[i]); } }); for (std::thread &th : pool) diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index a083fce..53d3058 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -357,7 +357,6 @@ struct SolidItemN { std::shared_ptr sweep; std::shared_ptr boolean; std::vector> faces; // shell operand - bool smooth_faceset = false; // explicit faceted rep -> relaxed weld crease }; // A CSG boolean: op(a, b). op 0=difference (cut), 1=union (fuse), 2=intersection (common). @@ -388,13 +387,6 @@ struct NgeomRoot { // reference kernel also refuses). Distinguishes "intentionally empty" from "unsupported": the // stream must NOT count these as products_skipped (they'd else drive a pointless OCC fallback). bool recognized_empty = false; - // This root is an EXPLICIT faceted representation (IfcFacetedBrep / Polygonal- or - // TriangulatedFaceSet): a curved surface approximated by many small planar facets. The GLB carries - // no normals, so the viewer shades from the welded geometry — but the default 40° weld crease - // splits coarse curved facets into hard edges, so a bowl looks faceted next to the smooth - // AdvancedBrep. Weld these roots with a relaxed crease so gentle facets shade smoothly while - // genuine ~90° edges (box corners, a basin rim) stay sharp. Set by the IFC reader. - bool smooth_faceset = false; // Presentation colour (STYLED_ITEM -> COLOUR_RGB), populated by the native STEP reader; the // NGEOM byte decoder leaves has_color=false (colour travels out-of-band on that path). bool has_color = false; From 9d0149e8c91cec2e6cd1a78bd6f15b144c845827 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 11 Jul 2026 17:21:43 +0200 Subject: [PATCH 50/65] perf: CSR grouping in weld_mesh (drop the map-of-vectors) weld_mesh grouped soup corners with unordered_map>, one map node + one heap-allocated vector PER soup vertex. A 61k-face solid's 16.9M-vertex soup churned ~2GB of tiny nodes/vectors. Replace with a dense first-appearance group id + a counting-sort CSR (gstart offsets / flat corners array): same data in ~0.5GB, zero per-vertex allocations. Corners stay in ascending-v order within a group, so the greedy crease clustering (and thus welded normals/topology) is bit-for-bit identical to the old weld. Output vertices are numbered in group (first-appearance) order rather than hash order -> geometrically identical, deterministically renumbered, and spatially coherent so meshopt compresses the merged GLB markedly better (469826: 130MB -> 83MB). Add tests/ngeom/test_weld.cpp: reproduces the old map-of-vectors weld verbatim as reference_weld() and asserts corner-by-corner parity (positions + normals + dedup count) across shared-edge/box/grid fixtures, plus crease semantics. NOTE: output vertex numbering changed -> golden-GLB byte tests need regold. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_weld.h | 61 ++++++--- tests/ngeom/run.sh | 2 +- tests/ngeom/test_weld.cpp | 241 ++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 tests/ngeom/test_weld.cpp diff --git a/src/geom/neutral/ngeom_weld.h b/src/geom/neutral/ngeom_weld.h index e536128..f67e770 100644 --- a/src/geom/neutral/ngeom_weld.h +++ b/src/geom/neutral/ngeom_weld.h @@ -57,7 +57,13 @@ inline void weld_mesh(std::vector &positions, std::vector &indi fnorm[t] = len > 1e-30 ? Vec3{n.x / len, n.y / len, n.z / len} : Vec3{0, 0, 1}; } - // Group soup corners by quantized position. + // Group soup corners by quantized position. A dense group id per unique key + a CSR + // (gstart offsets / corners) layout — one flat uint32 corner array instead of a + // std::vector-per-key. The old map-of-vectors did one heap allocation PER soup vertex, so a + // 61k-face solid's 16.9M-vertex soup churned ~2GB of tiny nodes/vectors; CSR holds the same + // data in ~0.5GB and never allocates per vertex. Bonus: numbering output verts in + // first-appearance (spatially coherent) order rather than hash order lets meshopt compress the + // merged GLB markedly better (469826: 130MB -> 83MB). Geometrically identical to the old weld. struct KeyHash { size_t operator()(const std::array &k) const { uint64_t h = 1469598103934665603ull; @@ -65,25 +71,50 @@ inline void weld_mesh(std::vector &positions, std::vector &indi return (size_t) h; } }; - std::unordered_map, std::vector, KeyHash> groups; - groups.reserve(nv); + // First-appearance dense group id per unique quantized position. gid_of[v] indexes the group of + // soup vertex v; groups are numbered in v order, so the CSR below lists each group's corners in + // ascending v — the same order the old per-key push_back produced, keeping the greedy crease + // clustering (and thus the welded normals/topology) identical. Output vertices are numbered in + // group order rather than the old hash order: geometrically identical, deterministically renumbered. + std::unordered_map, uint32_t, KeyHash> gid; + std::vector gid_of(nv); + for (size_t v = 0; v < nv; ++v) { + std::array key{qcoord(positions[3 * v]), qcoord(positions[3 * v + 1]), + qcoord(positions[3 * v + 2])}; + gid_of[v] = gid.emplace(key, (uint32_t) gid.size()).first->second; + } + const size_t ng = gid.size(); + // Counting sort corners into group order: gstart[g] is the start of group g's corner run. + std::vector gstart(ng + 1, 0); for (size_t v = 0; v < nv; ++v) - groups[{qcoord(positions[3 * v]), qcoord(positions[3 * v + 1]), qcoord(positions[3 * v + 2])}].push_back( - (uint32_t) v); + ++gstart[gid_of[v] + 1]; + for (size_t g = 0; g < ng; ++g) + gstart[g + 1] += gstart[g]; + std::vector corners(nv); + { + std::vector cur(gstart.begin(), gstart.end() - 1); // per-group write cursor + for (size_t v = 0; v < nv; ++v) + corners[cur[gid_of[v]]++] = (uint32_t) v; + } const double crease_cos = std::cos(crease_deg * PI / 180.0); std::vector npos, nnrm; std::vector remap(nv); npos.reserve(positions.size() / 4); nnrm.reserve(positions.size() / 4); - for (auto &[key, corners] : groups) { + std::vector cluster_dir; // representative (first face's normal), reused across groups + std::vector cluster_acc; // area-weighted accumulated normal + std::vector which; + std::vector cluster_vid; + for (size_t g = 0; g < ng; ++g) { + const uint32_t cs = gstart[g], nc = gstart[g + 1] - cs; // Cluster this position's incident-face normals by crease angle (greedy — a vertex's incident // faces are locally coherent, so smooth surfaces make one cluster, a 90-degree edge makes two). - std::vector cluster_dir; // representative (first face's normal) - std::vector cluster_acc; // area-weighted accumulated normal - std::vector which(corners.size()); - for (size_t ci = 0; ci < corners.size(); ++ci) { - size_t t = corners[ci] / 3; // soup: old vertex belongs to exactly one triangle + cluster_dir.clear(); + cluster_acc.clear(); + which.assign(nc, 0); + for (uint32_t ci = 0; ci < nc; ++ci) { + size_t t = corners[cs + ci] / 3; // soup: old vertex belongs to exactly one triangle const Vec3 &fn = fnorm[t]; int found = -1; for (size_t cl = 0; cl < cluster_dir.size(); ++cl) @@ -96,8 +127,8 @@ inline void weld_mesh(std::vector &positions, std::vector &indi cluster_acc[found] = cluster_acc[found] + fn * farea[t]; which[ci] = found; } - uint32_t p0 = corners[0]; - std::vector cluster_vid(cluster_dir.size()); + uint32_t p0 = corners[cs]; + cluster_vid.assign(cluster_dir.size(), 0); for (size_t cl = 0; cl < cluster_dir.size(); ++cl) { cluster_vid[cl] = (uint32_t) (npos.size() / 3); npos.push_back(positions[3 * p0]); @@ -112,8 +143,8 @@ inline void weld_mesh(std::vector &positions, std::vector &indi nnrm.push_back((float) un.z); } } - for (size_t ci = 0; ci < corners.size(); ++ci) - remap[corners[ci]] = cluster_vid[which[ci]]; + for (uint32_t ci = 0; ci < nc; ++ci) + remap[corners[cs + ci]] = cluster_vid[which[ci]]; } for (uint32_t &i : indices) diff --git a/tests/ngeom/run.sh b/tests/ngeom/run.sh index 96e299d..a8db4c3 100755 --- a/tests/ngeom/run.sh +++ b/tests/ngeom/run.sh @@ -21,7 +21,7 @@ g++ -std=c++20 -O2 -c -I third_party/meshoptimizer/src third_party/meshoptimizer mv ./*.o "$obj/mo"/ # header-only suites -for t in test_analytic test_bspline test_decode; do +for t in test_analytic test_bspline test_decode test_weld; do g++ -std=c++20 -O2 -Wall $INC "tests/ngeom/$t.cpp" -o "$obj/$t" "$obj/$t" done diff --git a/tests/ngeom/test_weld.cpp b/tests/ngeom/test_weld.cpp new file mode 100644 index 0000000..977c07f --- /dev/null +++ b/tests/ngeom/test_weld.cpp @@ -0,0 +1,241 @@ +// Parity test for weld_mesh (ngeom_weld.h): the CSR grouping must produce a mesh geometrically +// identical to the previous map-of-vectors grouping. The old algorithm is reproduced here verbatim +// as reference_weld(); both are run on the same fixtures and compared corner-by-corner (welded +// positions + smoothed/creased normals). Output vertices are renumbered by the rewrite, but every +// triangle reproduces the same original positions and the same per-corner normals, so a direct +// t-th-triangle comparison is exact (weld rewrites indices in place, never reorders triangles). +#include +#include +#include +#include +#include +#include + +#include "ngeom_weld.h" + +using adacpp::ngeom::Vec3; +using adacpp::ngeom::weld_mesh; + +static int g_fail = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); \ + ++g_fail; \ + } \ + } while (0) + +// ---- reference weld: the pre-CSR map-of-vectors implementation, verbatim --------------------------- +static void reference_weld(std::vector &positions, std::vector &indices, + std::vector &normals, double crease_deg = 40.0) { + const size_t nt = indices.size() / 3; + const size_t nv = positions.size() / 3; + if (nt == 0 || nv == 0) + return; + const bool have_normals = normals.size() == positions.size(); + Vec3 lo{1e300, 1e300, 1e300}, hi{-1e300, -1e300, -1e300}; + for (size_t v = 0; v < nv; ++v) { + double x = positions[3 * v], y = positions[3 * v + 1], z = positions[3 * v + 2]; + lo.x = std::min(lo.x, x); lo.y = std::min(lo.y, y); lo.z = std::min(lo.z, z); + hi.x = std::max(hi.x, x); hi.y = std::max(hi.y, y); hi.z = std::max(hi.z, z); + } + double diag = std::sqrt((hi.x - lo.x) * (hi.x - lo.x) + (hi.y - lo.y) * (hi.y - lo.y) + + (hi.z - lo.z) * (hi.z - lo.z)); + const double inv = 1.0 / (diag > 0 ? diag * 1e-6 : 1e-9); + auto qcoord = [&](double c) -> int64_t { return (int64_t) std::llround(c * inv); }; + std::vector fnorm(nt); + std::vector farea(nt); + for (size_t t = 0; t < nt; ++t) { + uint32_t ia = indices[3 * t], ib = indices[3 * t + 1], ic = indices[3 * t + 2]; + Vec3 a{positions[3 * ia], positions[3 * ia + 1], positions[3 * ia + 2]}; + Vec3 b{positions[3 * ib], positions[3 * ib + 1], positions[3 * ib + 2]}; + Vec3 c{positions[3 * ic], positions[3 * ic + 1], positions[3 * ic + 2]}; + Vec3 n = (b - a).cross(c - a); + double len = n.norm(); + farea[t] = 0.5 * len; + fnorm[t] = len > 1e-30 ? Vec3{n.x / len, n.y / len, n.z / len} : Vec3{0, 0, 1}; + } + struct KeyHash { + size_t operator()(const std::array &k) const { + uint64_t h = 1469598103934665603ull; + for (int64_t q : k) { h ^= (uint64_t) q; h *= 1099511628211ull; } + return (size_t) h; + } + }; + std::unordered_map, std::vector, KeyHash> groups; + groups.reserve(nv); + for (size_t v = 0; v < nv; ++v) + groups[{qcoord(positions[3 * v]), qcoord(positions[3 * v + 1]), qcoord(positions[3 * v + 2])}].push_back( + (uint32_t) v); + const double crease_cos = std::cos(crease_deg * 3.14159265358979323846 / 180.0); + std::vector npos, nnrm; + std::vector remap(nv); + for (auto &[key, corners] : groups) { + std::vector cluster_dir, cluster_acc; + std::vector which(corners.size()); + for (size_t ci = 0; ci < corners.size(); ++ci) { + size_t t = corners[ci] / 3; + const Vec3 &fn = fnorm[t]; + int found = -1; + for (size_t cl = 0; cl < cluster_dir.size(); ++cl) + if (cluster_dir[cl].dot(fn) >= crease_cos) { found = (int) cl; break; } + if (found < 0) { + found = (int) cluster_dir.size(); + cluster_dir.push_back(fn); + cluster_acc.push_back(Vec3{0, 0, 0}); + } + cluster_acc[found] = cluster_acc[found] + fn * farea[t]; + which[ci] = found; + } + uint32_t p0 = corners[0]; + std::vector cluster_vid(cluster_dir.size()); + for (size_t cl = 0; cl < cluster_dir.size(); ++cl) { + cluster_vid[cl] = (uint32_t) (npos.size() / 3); + npos.push_back(positions[3 * p0]); + npos.push_back(positions[3 * p0 + 1]); + npos.push_back(positions[3 * p0 + 2]); + if (have_normals) { + Vec3 n = cluster_acc[cl]; + double l = n.norm(); + Vec3 un = l > 1e-30 ? Vec3{n.x / l, n.y / l, n.z / l} : cluster_dir[cl]; + nnrm.push_back((float) un.x); + nnrm.push_back((float) un.y); + nnrm.push_back((float) un.z); + } + } + for (size_t ci = 0; ci < corners.size(); ++ci) + remap[corners[ci]] = cluster_vid[which[ci]]; + } + for (uint32_t &i : indices) + i = remap[i]; + positions = std::move(npos); + if (have_normals) + normals = std::move(nnrm); +} + +// ---- fixtures -------------------------------------------------------------------------------------- + +struct Mesh { + std::vector pos, nrm; + std::vector idx; +}; + +// A flat-shaded triangle soup: one triangle emits 3 fresh verts, each carrying the face normal. +static void add_tri(Mesh &m, Vec3 a, Vec3 b, Vec3 c) { + Vec3 n = (b - a).cross(c - a); + double l = n.norm(); + Vec3 fn = l > 1e-30 ? Vec3{n.x / l, n.y / l, n.z / l} : Vec3{0, 0, 1}; + for (const Vec3 &p : {a, b, c}) { + uint32_t base = (uint32_t) (m.pos.size() / 3); + m.pos.push_back((float) p.x); m.pos.push_back((float) p.y); m.pos.push_back((float) p.z); + m.nrm.push_back((float) fn.x); m.nrm.push_back((float) fn.y); m.nrm.push_back((float) fn.z); + m.idx.push_back(base); + } +} + +static void add_quad(Mesh &m, Vec3 a, Vec3 b, Vec3 c, Vec3 d) { + add_tri(m, a, b, c); + add_tri(m, a, c, d); +} + +// Axis-aligned box as a flat soup (12 tris) — every corner is shared by 3 faces at 90 degrees, so +// welding must SPLIT it into (at least) 3 crease clusters, not average across the sharp edges. +static Mesh make_box(double s) { + Mesh m; + Vec3 p000{0, 0, 0}, p100{s, 0, 0}, p110{s, s, 0}, p010{0, s, 0}; + Vec3 p001{0, 0, s}, p101{s, 0, s}, p111{s, s, s}, p011{0, s, s}; + add_quad(m, p000, p010, p110, p100); // z- + add_quad(m, p001, p101, p111, p011); // z+ + add_quad(m, p000, p100, p101, p001); // y- + add_quad(m, p010, p011, p111, p110); // y+ + add_quad(m, p000, p001, p011, p010); // x- + add_quad(m, p100, p110, p111, p101); // x+ + return m; +} + +// A tessellated flat plate (grid of coplanar quads) — all incident faces coplanar, so every shared +// vertex welds into ONE smooth cluster; exercises high fan-in (interior verts shared by 6 corners). +static Mesh make_grid(int n, double s) { + Mesh m; + double d = s / n; + for (int i = 0; i < n; ++i) + for (int j = 0; j < n; ++j) { + Vec3 a{i * d, j * d, 0}, b{(i + 1) * d, j * d, 0}, c{(i + 1) * d, (j + 1) * d, 0}, e{i * d, (j + 1) * d, 0}; + add_quad(m, a, b, c, e); + } + return m; +} + +// ---- comparison ------------------------------------------------------------------------------------ + +static bool feq(float a, float b, float eps = 1e-5f) { return std::fabs(a - b) <= eps; } + +// Both welds reproduce input triangle t from the same original positions and rewrite indices in place +// (no triangle reordering), so compare corner-by-corner: welded position must equal the ORIGINAL soup +// position, and the two impls' per-corner normals must match. Also require identical dedup vertex count. +static void compare(const char *name, const Mesh &orig) { + Mesh a = orig, b = orig; // fresh copies (weld rewrites in place) + weld_mesh(a.pos, a.idx, a.nrm); + reference_weld(b.pos, b.idx, b.nrm); + + CHECK(a.pos.size() == b.pos.size(), name); // same dedup vertex count + CHECK(a.idx.size() == b.idx.size() && a.idx.size() == orig.idx.size(), name); + + const size_t nt = orig.idx.size() / 3; + bool pos_ok = true, nrm_ok = true; + for (size_t k = 0; k < orig.idx.size(); ++k) { + uint32_t oc = orig.idx[k]; // original soup corner index + uint32_t ai = a.idx[k], bi = b.idx[k]; + for (int t = 0; t < 3; ++t) { + if (!feq(a.pos[3 * ai + t], orig.pos[3 * oc + t])) + pos_ok = false; // new weld must preserve the original corner position + if (!feq(a.pos[3 * ai + t], b.pos[3 * bi + t])) + pos_ok = false; + if (!feq(a.nrm[3 * ai + t], b.nrm[3 * bi + t])) + nrm_ok = false; // new normal == reference normal for this corner + } + } + CHECK(pos_ok, name); + CHECK(nrm_ok, name); + std::printf(" %-16s tris=%zu verts: soup=%zu welded=%zu (ref=%zu) pos=%s nrm=%s\n", name, nt, + orig.pos.size() / 3, a.pos.size() / 3, b.pos.size() / 3, pos_ok ? "ok" : "DIFF", + nrm_ok ? "ok" : "DIFF"); +} + +// Independent sanity checks on the CSR output itself (not just parity vs the old code). +static void semantic_checks() { + // Flat grid: interior vertices must fully weld (welded count == unique grid nodes). + Mesh g = make_grid(4, 1.0); + Mesh w = g; + weld_mesh(w.pos, w.idx, w.nrm); + CHECK(w.pos.size() / 3 == 25, "grid welds to (n+1)^2 nodes"); // 5x5 lattice, one smooth cluster each + // Box: 8 geometric corners, each split into 3 crease clusters -> 24 welded verts. + Mesh bx = make_box(2.0); + Mesh wb = bx; + weld_mesh(wb.pos, wb.idx, wb.nrm); + CHECK(wb.pos.size() / 3 == 24, "box corners split into 3 crease clusters each"); + // Every box normal axis-aligned unit (crease kept the flat face normals, no averaging across edges). + bool axis = true; + for (size_t v = 0; v < wb.nrm.size() / 3; ++v) { + float x = std::fabs(wb.nrm[3 * v]), y = std::fabs(wb.nrm[3 * v + 1]), z = std::fabs(wb.nrm[3 * v + 2]); + float mx = std::max(x, std::max(y, z)); + if (!feq(mx, 1.0f, 1e-4f)) + axis = false; + } + CHECK(axis, "box welded normals stay axis-aligned (crease preserved)"); +} + +int main() { + std::printf("weld_mesh CSR parity + semantics:\n"); + compare("shared-edge", [] { Mesh m; add_quad(m, {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}); return m; }()); + compare("box", make_box(2.0)); + compare("grid8", make_grid(8, 3.0)); + compare("grid32", make_grid(32, 10.0)); // 2048 tris, high fan-in, scale stress + semantic_checks(); + if (g_fail) { + std::printf("weld: %d checks FAILED\n", g_fail); + return 1; + } + std::printf("weld: all checks passed\n"); + return 0; +} From ae8fb6b40ba674a0f2fc1833b7ec562e88aac167 Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 11 Jul 2026 17:21:54 +0200 Subject: [PATCH 51/65] perf: bound parse_cache_ on the phase-A resolver (STEP->GLB) The native STEP->GLB stream held every parsed STEP statement of a giant shell resident during its single resolve: 469826's 61k-face monster piled parse_cache_ up to ~1.8GB, the whole conversion's memory floor. Resolver already has enable_parse_cache_bounding() (drops parse_cache_ every 1024 faces mid-shell, retaining the built ng:: geometry), gated single-threaded because the concurrent pool would force extra re-parsing through the shared StreamIndex. Phase A resolves each huge solid SINGLE-THREADED (only tessellate_doc's face pool is parallel), so enabling it on the phase-A resolver r0 satisfies that constraint; statement_bytes is pread-based (thread-safe, per-resolver scratch). Phase B's concurrent pool stays unbounded -- its solids are all below the huge threshold, so bounding there would be a no-op anyway. Measured on 469826 (nt=3, adaptive): peak RSS 2840 -> 1286 MB (-55%), conversion time unchanged, GLB geometrically identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/step_to_glb_stream.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 83a92cc..9b8d8e5 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -192,6 +192,14 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou tph.threads = nthreads; adacpp::step::Resolver r0(idx); r0.copy_metadata_from(master); + // Phase A resolves each huge solid SINGLE-THREADED (only tessellate_doc's face pool + // below is parallel), so parse-cache bounding is safe here — the constraint the flag + // documents. Without it, a 61k-face monster's parsed STEP statements pile up to ~2GB + // during its one resolve (469826's memory floor); bounding drops that shell to ~432MB + // by dropping parse_cache_ every 1024 faces (built ng:: geometry is retained). Phase B + // (the concurrent pool) stays unbounded — its solids are all below the huge threshold. + // Measured on 469826: peak RSS 2840 -> 1257 MB (-56%), conversion time unchanged. + r0.enable_parse_cache_bounding(); double busy_ms = 0; std::chrono::steady_clock::time_point b0; if (tprof) From 5fe25ba5bcb3a9842b79f2ed0eac660ed4387bcc Mon Sep 17 00:00:00 2001 From: krande Date: Sat, 11 Jul 2026 19:21:01 +0200 Subject: [PATCH 52/65] perf: bound parse_cache_ on the phase-A resolver (STEP->mesh) Same fix as the STEP->GLB path (previous commit), applied to the obj/stl streamer. step_to_mesh_stream.h's phase-A resolver resolved each huge solid without parse-cache bounding, so 469826's 61k-face monster piled parse_cache_ to ~1.8GB and OOMed obj/stl conversions (GLB was already fixed; mesh shares the identical phase-A structure but was missed). Enable bounding on the phase-A resolver r0 (single-threaded resolve there, so it's safe). Measured on 469826: obj peak 1262MB / stl 1300MB (were OOM), matching the GLB path's 1286MB. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/step_to_mesh_stream.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index 4f59423..d1d4ab9 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -266,6 +266,12 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o tph.threads = nthreads; adacpp::step::Resolver r0(idx); r0.copy_metadata_from(master); + // Phase A resolves each huge solid single-threaded (only tessellate_doc's face pool is + // parallel), so bound parse_cache_ here — without it a 61k-face monster's parsed STEP + // statements pile up to ~2GB during its one resolve (469826's obj/stl OOM), same as the + // STEP->GLB path. Drops that shell to ~432MB by clearing parse_cache_ every 1024 faces + // (built ng:: geometry is retained). Phase B (concurrent pool) stays unbounded. + r0.enable_parse_cache_bounding(); for (size_t i = 0; i < n_huge; ++i) { process_root(r0, lanes[0], i, tph); r0.clear_geom_cache(); From 267e0b7db22dc4c84937761afb70252fb6f4efbe Mon Sep 17 00:00:00 2001 From: krande Date: Sun, 12 Jul 2026 02:09:11 +0200 Subject: [PATCH 53/65] perf: mid-product cache_ bounding in IfcResolver (huge single-product shells) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IFC->GLB streamer's phase-A resolver had no mid-product bound: a single huge product (e.g. a 61k-face IfcClosedShell/IfcFacetedBrep) piled parsed Part-21 statements into cache_ during its one resolve, the IFC analogue of the STEP glb/mesh OOM already fixed. Add enable_cache_bounding() — drop cache_ (NOT the built surf_cache_) every 1024 faces, mirroring the STEP reader's enable_parse_cache_bounding — and enable it on the single-threaded phase-A resolver r0 (phase B stays unbounded, bounds per-product via clear_cache()). Refactor to make the bound reachable + safe: a shared iter_faces_bounded() helper (copies the face-ref list first, since the clear frees the parent shell Instance) used by add_shell_faces and IFCPOLYGONALFACESET; IFCADVANCEDBREP/ IFCFACETEDBREP now route through add_shell_faces instead of an inline loop. Behaviour is unchanged when bounding is off (default): same faces, same order. Verified: fixtures (surface-model / polygonal / faceted-brep / tessellated) all produce GLBs; 469826.ifc converts without OOM. (IFC peak on 469826 is still ~5GB — that file's memory is dominated by non-shell factors, not this bound; separate follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 77 +++++++++++++++++++++++++------------ src/cad/ifc_to_glb_stream.h | 4 ++ 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index bd2ca0e..b55c549 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -463,6 +463,15 @@ class IfcResolver { surf_cache_.clear(); } + // Bound cache_ MID-product for a huge single-product shell: drop the parsed Part-21 statements + // (NOT the built-geometry surf_cache_) every 1024 faces so a 61k-face brep doesn't pile up + // hundreds of MB of parsed face/bound/loop/edge/vertex entities during its one resolve — the IFC + // analogue of the STEP reader's enable_parse_cache_bounding (see step_reader.h). Faces re-parse + // their sub-entities on demand via the pread offset index. Enable ONLY on a single-threaded + // (phase-A) resolver; the concurrent phase-B pool leaves it off and bounds via clear_cache() per + // product. A no-op for products under 1024 faces (the mid-shell clear never fires). + void enable_cache_bounding() { bound_cache_ = true; } + // Build the expensive, read-only, cross-product metadata (colour + spatial-hierarchy maps) ONCE on // a master resolver so parallel workers can share it via copy_metadata_from instead of each // rebuilding it (both scan every entity in the file). clear_cache() must not wipe these. @@ -533,6 +542,7 @@ class IfcResolver { const StreamIndex &idx_; std::unordered_map> cache_; std::unordered_map> surf_cache_; + bool bound_cache_ = false; // enable_cache_bounding(): drop cache_ mid-shell on huge products std::string pread_scratch_; long solid_src_ = 0; // entity id of the one solid this product carries (mapped instances share it) bool mixed_ = false; // product has >1 distinct solid / mixes brep+procedural -> skip (OCC) @@ -1422,32 +1432,30 @@ class IfcResolver { } else if (iequals(t, "IFCBOOLEANRESULT") || iequals(t, "IFCBOOLEANCLIPPINGRESULT")) { out.boolean = mk_boolean(in); } else if (iequals(t, "IFCADVANCEDBREP") || iequals(t, "IFCFACETEDBREP")) { - const Instance *shell = inst(ref_arg(*in, 0)); - if (shell && !shell->args.empty() && shell->args[0].is_list()) - for (const Value &fref : shell->args[0].items) - if (fref.is_ref()) - if (auto f = face(fref.i)) - out.faces.push_back(f); + add_shell_faces(ref_arg(*in, 0), out); // shell (arg 0) — bounded face loop, same as shells below } else if (iequals(t, "IFCHALFSPACESOLID")) { if (auto ex = mk_halfspace(in, refmin, refmax)) out.extrusion = ex; } else if (iequals(t, "IFCPOLYGONALFACESET")) { // (Coordinates=IfcCartesianPointList3D, Closed, Faces=IfcIndexedPolygonalFace[], PnIndex) - std::vector pts = point_list_3d(ref_arg(*in, 0)); + std::vector pts = point_list_3d(ref_arg(*in, 0)); // own copy — survives a cache_ clear + std::vector face_ids; if (in->args.size() > 2 && in->args[2].is_list()) - for (const Value &fref : in->args[2].items) - if (fref.is_ref()) { - const Instance *pf = inst(fref.i); // IfcIndexedPolygonalFace(CoordIndex[, Inner]) - if (!pf || pf->args.empty() || !pf->args[0].is_list()) - continue; - std::vector poly; - for (const Value &iv : pf->args[0].items) - push_pt(poly, pts, iv); - if (auto f = make_profile(poly)) { - f->fit_plane_from_loop = true; // 3D face: fit its real plane, not z=0 - out.faces.push_back(f); - } - } + for (const Value &fref : in->args[2].items) // copy first — the loop below may clear cache_ + if (fref.is_ref()) + face_ids.push_back(fref.i); + iter_faces_bounded(face_ids, [&](long fid) { + const Instance *pf = inst(fid); // IfcIndexedPolygonalFace(CoordIndex[, Inner]) + if (!pf || pf->args.empty() || !pf->args[0].is_list()) + return; + std::vector poly; + for (const Value &iv : pf->args[0].items) + push_pt(poly, pts, iv); + if (auto f = make_profile(poly)) { + f->fit_plane_from_loop = true; // 3D face: fit its real plane, not z=0 + out.faces.push_back(f); + } + }); } else if (iequals(t, "IFCTRIANGULATEDFACESET")) { // (Coordinates, Normals, Closed, CoordIndex=list of (i,j,k) triples, PnIndex) std::vector pts = point_list_3d(ref_arg(*in, 0)); @@ -1929,14 +1937,33 @@ class IfcResolver { if (ix >= 1 && (size_t) ix < pts.size()) poly.push_back(pts[ix]); } + // Iterate a COPIED list of face ref-ids, invoking emit(id) per face, dropping cache_ (parsed + // statements) every 1024 faces when bounding is on. Callers MUST copy the ids out first: the clear + // frees the parent shell/faceset Instance, so iterating its arg list in place would use-after-free + // (same hazard the STEP reader guards). Built geometry (surf_cache_) + persistent maps are kept. + template + void iter_faces_bounded(const std::vector &face_ids, Emit emit) { + for (size_t i = 0; i < face_ids.size(); ++i) { + emit(face_ids[i]); + if (bound_cache_ && (i & 1023u) == 1023u) + cache_.clear(); + } + } + // A shell (IfcClosedShell/IfcOpenShell/IfcConnectedFaceSet): CfsFaces (arg 0) is a list of IfcFace. void add_shell_faces(long shell_id, SolidItemN &out) { const Instance *sh = inst(shell_id); - if (sh && !sh->args.empty() && sh->args[0].is_list()) - for (const Value &fref : sh->args[0].items) - if (fref.is_ref()) - if (auto f = face(fref.i)) - out.faces.push_back(f); + if (!sh || sh->args.empty() || !sh->args[0].is_list()) + return; + std::vector face_ids; + face_ids.reserve(sh->args[0].items.size()); + for (const Value &fref : sh->args[0].items) // copy first — iter_faces_bounded may clear cache_ + if (fref.is_ref()) + face_ids.push_back(fref.i); + iter_faces_bounded(face_ids, [&](long fid) { + if (auto f = face(fid)) + out.faces.push_back(f); + }); } // IfcBooleanResult(Operator, FirstOperand, SecondOperand) -> ng::BooleanN (null if an operand can't // be resolved). op: 0 difference / 1 union / 2 intersection. The 1st operand bounds a half-space 2nd. diff --git a/src/cad/ifc_to_glb_stream.h b/src/cad/ifc_to_glb_stream.h index 70db8be..f3e6100 100644 --- a/src/cad/ifc_to_glb_stream.h +++ b/src/cad/ifc_to_glb_stream.h @@ -149,6 +149,10 @@ inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out tph.threads = nthreads; adacpp::ifc_read::IfcResolver r0(idx); r0.copy_metadata_from(master); + // Phase A resolves each huge product single-threaded (only tessellate_doc's face pool is + // parallel), so bound cache_ mid-shell — without it a single 61k-face IFC product piles + // parsed statements to ~GB during its one resolve, same failure the STEP glb/mesh paths hit. + r0.enable_cache_bounding(); for (size_t i = 0; i < n_huge; ++i) process(r0, lanes[0], i, tph); adacpp::mem_trim(); From b83a8f9cce1c31247baf8b6417820a662e357348 Mon Sep 17 00:00:00 2001 From: krande Date: Sun, 12 Jul 2026 18:56:47 +0200 Subject: [PATCH 54/65] fix: spawned face-worker threads lost adaptive density (thread-local model_scale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the huge single-root face-parallel path of tessellate_one_root, tls_model_scale is thread_local and was set only on the calling thread. The spawned face workers started at 0.0 => adaptive density OFF => full fine tessellation for whatever share of the faces they grabbed. A huge solid's triangle count therefore SCALED WITH THREAD COUNT and varied run-to-run with scheduling (measured on 469826: 12.44M serial -> 14.6M at 4 threads -> 15.85M at 8). Publish the model scale on each worker so every face uses the adaptive density: tri count is now thread-invariant and deterministic (12,438,375 at 1/4/8 threads), and multithread output shrinks ~15-21%. Also batch the face-parallel tessellation + in-order merge (ADA_TESS_FACE_MERGE_BATCH, default 4096; 0 = old all-at-once) so the resident per-face local meshes never exceed one batch — on a 61k-face monster the full locals[] is a second complete copy of the solid soup (~0.5 GB). Byte-identical output (same face-order append). Defensive memory hygiene; not the dominant term in the 469826 obj/stl peak (that is fork/arena RSS in the worker, conversion intrinsic ~1.3 GB). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_tessellate.cpp | 70 ++++++++++++++++++++------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 4867f46..607adb0 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -38,6 +38,23 @@ const bool FDBG = std::getenv("NGEOM_FDBG") != nullptr; // triangle counts, all tess params) — for head-to-head comparison with the reference tessellator's face-debug dump. const bool TESSDBG = std::getenv("NGEOM_TESSDBG") != nullptr; +// Face-merge batch size for the huge single-root face-parallel path (tessellate_one_root). The +// per-face local meshes are held resident until merged; on a 61k-face monster solid the full +// locals[] is a SECOND complete copy of the solid's soup (~0.5 GB with normals), inflating that +// root's transient peak. Processing faces in BATCHES caps the resident local meshes to one batch, +// keeping the merge byte-identical (same face-order append). Defensive memory hygiene — measurably +// trims the face-parallel merge transient, though it is not the dominant term in the 469826 case. +// ADA_TESS_FACE_MERGE_BATCH: batch size (default 4096); 0 disables batching (old all-at-once path). +inline size_t tess_face_merge_batch() { + if (const char *e = std::getenv("ADA_TESS_FACE_MERGE_BATCH")) { + char *end = nullptr; + long v = std::strtol(e, &end, 10); + if (end != e && v >= 0) + return (size_t) v; + } + return 4096; +} + // ---- diagnostics (env-gated) ---------------------------------------------------------------- [[maybe_unused]] const char *surf_kind(const Surface &s) { if (dynamic_cast(&s)) @@ -1899,25 +1916,44 @@ static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, Tes // the serial loop. The 64-face floor keeps small solids on the cheap path. if (tp.threads > 1 && root.faces.size() >= 64) { const size_t n = root.faces.size(); - std::vector locals(n); - std::atomic next{0}; TessParams tpl = tp; tpl.threads = 1; // no nested pools inside a face - unsigned nt = std::min((unsigned) tp.threads, (unsigned) n); - std::vector pool; - pool.reserve(nt - 1); - auto face_worker = [&]() { - for (size_t i = next.fetch_add(1); i < n; i = next.fetch_add(1)) - if (root.faces[i]) - tessellate_face(*root.faces[i], tpl, locals[i]); - }; - for (unsigned t = 1; t < nt; ++t) - pool.emplace_back(face_worker); - face_worker(); - for (std::thread &th : pool) - th.join(); - for (const TessMesh &lm : locals) - append_mesh(out, lm); + // Batch the parallel face tessellation + in-order merge so the resident per-face local + // meshes never exceed one batch (a 61k-face locals[] is otherwise a full second copy of + // the solid soup ~0.5 GB — the 469826 obj/stl OOM). Faces within a batch run in parallel; + // batches (and faces within them) merge in face order, so the output is byte-identical to + // the old all-at-once path. batch==0 or >=n => single batch (the original behaviour). + size_t batch = tess_face_merge_batch(); + if (batch == 0 || batch > n) + batch = n; + for (size_t b0 = 0; b0 < n; b0 += batch) { + const size_t b1 = std::min(n, b0 + batch); + std::vector locals(b1 - b0); + std::atomic next{b0}; + unsigned nt = std::min((unsigned) tp.threads, (unsigned) (b1 - b0)); + auto face_worker = [&]() { + // tls_model_scale is thread_local, set (line above) ONLY on the thread that runs + // tessellate_one_root. The SPAWNED face workers start at 0.0 => adaptive density + // OFF => full fine tessellation for whatever share of the faces they grab. So a + // huge solid's triangle count SCALED WITH THREAD COUNT and varied run-to-run with + // scheduling (measured 469826: 12.44M serial -> 14.6M at 4 threads -> 15.85M at 8; + // ~+17% at the 3-thread prod pod). Publishing the model scale on THIS worker makes + // every face use the adaptive density => tri count is thread-invariant + deterministic. + tls_model_scale() = tp.model_scale; + for (size_t i = next.fetch_add(1); i < b1; i = next.fetch_add(1)) + if (root.faces[i]) + tessellate_face(*root.faces[i], tpl, locals[i - b0]); + }; + std::vector pool; + pool.reserve(nt - 1); + for (unsigned t = 1; t < nt; ++t) + pool.emplace_back(face_worker); + face_worker(); + for (std::thread &th : pool) + th.join(); + for (const TessMesh &lm : locals) + append_mesh(out, lm); + } // locals freed here -> next batch's peak is one batch, not all n faces return; } for (const auto &face : root.faces) From fb9284a7f3a2ef7f2e640b5c1c930a8e79fe0922 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 07:19:54 +0200 Subject: [PATCH 55/65] perf: buffer GLB spill lanes in RAM until a threshold (cut nvme write pressure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's speed regression was root-caused (node PSI + per-cgroup io.stat) to nvme write saturation: with two worker replicas + garage on one disk, util sat >85% for ~29% of a run, stalling lightweight conversions 10-52s (files that convert in ~0.5s in isolation). Attribution over a heavy window: worker spill = 72% of disk writes (50 MB/s), garage = 28% (20 MB/s). GlbSpillWriter wrote every material's baked verts/indices straight to per-material temp files, so even a tiny model created + wrote disk files. Buffer each material's pos/idx in RAM and only spill to a file once the LANE's total in-RAM bytes cross a threshold (ADA_GLB_SPILL_THRESHOLD_MB, default 96; 0 = always spill). Below it a model stays entirely in memory and does ZERO spill writes — the common case, since the corpus is mostly small/medium models. A huge solid/dense model still spills and streams from disk, so peak RAM stays bounded (lane-total cap, not per-material, so a many-material model can't pile up N thresholds on the 6Gi worker). The merge reads each material from its buffer or file via MatLane::for_idx/for_pos. Verified byte-identical GLB (nt=1 deterministic order) across in-RAM / force-spill / threshold-crossing: md5 95e853d4b075 for 469826 all three ways. ngeom suite green. Follow-up: same buffering for the STEP->obj/stl MeshLane spill. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_glb.h | 149 +++++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 35 deletions(-) diff --git a/src/geom/neutral/ngeom_glb.h b/src/geom/neutral/ngeom_glb.h index 127f385..2067296 100644 --- a/src/geom/neutral/ngeom_glb.h +++ b/src/geom/neutral/ngeom_glb.h @@ -516,15 +516,33 @@ inline bool write_glb(const std::string &path, const std::vector &soli }); } +// Per-material buffer/spill threshold (bytes, pos+idx combined). Below it a material stays entirely in +// RAM and never touches the disk — most conversions (small/medium models) then do ZERO spill writes, +// which is the dominant (~70%) source of the audit's nvme write pressure. A material that grows past it +// (a huge solid / dense model) spills to disk and streams from there, keeping peak RAM bounded. Env +// ADA_GLB_SPILL_THRESHOLD_MB (default 96); 0 forces the always-spill behaviour. +inline size_t glb_spill_threshold_bytes() { + if (const char *e = std::getenv("ADA_GLB_SPILL_THRESHOLD_MB")) { + char *end = nullptr; + long v = std::strtol(e, &end, 10); + if (end != e && v >= 0) + return (size_t) v * 1024 * 1024; + } + return (size_t) 96 * 1024 * 1024; +} + // Disk-spilling writer: one "lane" (one producer / worker). add() bakes + appends each material's -// verts/indices to per-material temp files; the merge (write_glb_merged) streams them into the GLB. -// Keeps only per-material metadata in RAM, never the merged buffers. +// verts/indices; small materials stay in an in-RAM buffer, large ones spill to per-material temp files. +// The merge (write_glb_merged) reads each material from its buffer or file. Keeps only per-material +// metadata + (for small models) the raw bytes in RAM, never the merged buffers. class GlbSpillWriter { public: struct MatLane { std::array color{0.5f, 0.5f, 0.5f, 1.0f}; - std::string pos_path, idx_path; + std::string pos_path, idx_path; // set only once this material has spilled to disk std::ofstream pos, idx; + std::string pos_buf, idx_buf; // in-RAM bytes while !spilled (then flushed to file + cleared) + bool spilled = false; uint32_t vert_count = 0, index_count = 0, idx_max = 0; float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f}; struct Part { @@ -533,9 +551,44 @@ class GlbSpillWriter { std::vector> path; }; std::vector parts; // per-solid (id, index_count, assembly path) in add order + + // Yield this material's index bytes as uint32 chunks — from the RAM buffer or streamed from the + // spill file — so the merge readers don't care which. f(const uint32_t*, count). + template void for_idx(F f) const { + if (!spilled) { + if (!idx_buf.empty()) + f(reinterpret_cast(idx_buf.data()), idx_buf.size() / 4); + return; + } + std::ifstream xf(idx_path, std::ios::binary); + std::vector rb(1u << 16); + while (xf) { + xf.read(reinterpret_cast(rb.data()), (std::streamsize) (rb.size() * 4)); + size_t got = (size_t) (xf.gcount() / 4); + if (got) + f(rb.data(), got); + } + } + // Yield this material's position bytes as raw chunks. f(const char*, nbytes). + template void for_pos(F f) const { + if (!spilled) { + if (!pos_buf.empty()) + f(pos_buf.data(), pos_buf.size()); + return; + } + std::ifstream pf(pos_path, std::ios::binary); + std::vector b(1u << 16); + while (pf) { + pf.read(b.data(), (std::streamsize) b.size()); + std::streamsize got = pf.gcount(); + if (got > 0) + f(b.data(), (size_t) got); + } + } }; - GlbSpillWriter(std::string dir, int lane) : dir_(std::move(dir)), lane_(lane) {} + GlbSpillWriter(std::string dir, int lane) + : dir_(std::move(dir)), lane_(lane), threshold_(glb_spill_threshold_bytes()) {} ~GlbSpillWriter() { remove_files(); } @@ -546,8 +599,6 @@ class GlbSpillWriter { return; std::array key = colour_key(s.color); MatLane &m = mats_[key]; - if (m.pos_path.empty()) - open(m, key); m.color = s.color; size_t ninst = s.transforms.empty() ? 1 : s.transforms.size(); for (size_t t = 0; t < ninst; ++t) { @@ -557,7 +608,7 @@ class GlbSpillWriter { for (size_t i = 0; i + 2 < s.positions.size(); i += 3) { float o[3]; xform(M, s.positions[i], s.positions[i + 1], s.positions[i + 2], o[0], o[1], o[2]); - m.pos.write(reinterpret_cast(o), 12); + append(m, key, /*is_pos=*/true, reinterpret_cast(o), 12); for (int k = 0; k < 3; ++k) { m.lo[k] = std::min(m.lo[k], o[k]); m.hi[k] = std::max(m.hi[k], o[k]); @@ -566,7 +617,7 @@ class GlbSpillWriter { } for (uint32_t ix : s.indices) { uint32_t v = base + ix; - m.idx.write(reinterpret_cast(&v), 4); + append(m, key, /*is_pos=*/false, reinterpret_cast(&v), 4); m.idx_max = std::max(m.idx_max, v); ++m.index_count; } @@ -575,26 +626,57 @@ class GlbSpillWriter { } } void flush() { - for (auto &[k, m] : mats_) { - m.pos.flush(); - m.idx.flush(); - } + for (auto &[k, m] : mats_) + if (m.spilled) { + m.pos.flush(); + m.idx.flush(); + } } const std::map, MatLane> &mats() const { return mats_; } private: - void open(MatLane &m, const std::array &key) { + // Append bytes to a material's pos/idx — into the RAM buffer, or the spill file once spilled. The + // threshold bounds the LANE's total in-RAM bytes (across all its materials), not one material's, so + // a many-material model can't pile up N thresholds of RAM on the memory-capped worker: crossing it + // spills the material currently being appended (freeing its buffer) and, if still over, the rest. + void append(MatLane &m, const std::array &key, bool is_pos, const char *p, size_t n) { + if (m.spilled) { + (is_pos ? m.pos : m.idx).write(p, (std::streamsize) n); + return; + } + (is_pos ? m.pos_buf : m.idx_buf).append(p, n); + buffered_ += n; + if (threshold_ && buffered_ > threshold_) { + spill(m, key); // the hot material first + for (auto &[k2, m2] : mats_) { + if (buffered_ <= threshold_) + break; + if (!m2.spilled && !(m2.pos_buf.empty() && m2.idx_buf.empty())) + spill(m2, k2); + } + } + } + // Move a material from RAM to disk: open its files, write the accumulated buffers, free the RAM. + void spill(MatLane &m, const std::array &key) { char tag[64]; std::snprintf(tag, sizeof tag, "/glb_l%d_%d_%d_%d_%d", lane_, key[0], key[1], key[2], key[3]); m.pos_path = dir_ + tag + ".pos"; m.idx_path = dir_ + tag + ".idx"; m.pos.open(m.pos_path, std::ios::binary); m.idx.open(m.idx_path, std::ios::binary); + m.pos.write(m.pos_buf.data(), (std::streamsize) m.pos_buf.size()); + m.idx.write(m.idx_buf.data(), (std::streamsize) m.idx_buf.size()); + buffered_ -= (m.pos_buf.size() + m.idx_buf.size()); + std::string().swap(m.pos_buf); + std::string().swap(m.idx_buf); + m.spilled = true; } void remove_files() { for (auto &[k, m] : mats_) { + if (!m.spilled) + continue; m.pos.close(); m.idx.close(); if (!m.pos_path.empty()) @@ -605,6 +687,8 @@ class GlbSpillWriter { } std::string dir_; int lane_; + size_t threshold_; + size_t buffered_ = 0; // total in-RAM buffer bytes across all not-yet-spilled materials in this lane std::map, MatLane> mats_; }; @@ -677,14 +761,10 @@ inline bool write_glb_merged(const std::string &path, const std::vectormats().end()) continue; const auto &m = it->second; - std::ifstream xf(m.idx_path, std::ios::binary); - std::vector rb(1u << 16); - while (xf) { - xf.read(reinterpret_cast(rb.data()), (std::streamsize) (rb.size() * 4)); - size_t got = (size_t) (xf.gcount() / 4); + m.for_idx([&](const uint32_t *rb, size_t got) { for (size_t k = 0; k < got; ++k) idx.push_back(rb[k] + vbase); - } + }); vbase += m.vert_count; } }; @@ -695,10 +775,13 @@ inline bool write_glb_merged(const std::string &path, const std::vectormats().end()) continue; const auto &m = it->second; - size_t nf = (size_t) m.vert_count * 3, old = pos.size(); - pos.resize(old + nf); - std::ifstream pf(m.pos_path, std::ios::binary); - pf.read(reinterpret_cast(pos.data() + old), (std::streamsize) (nf * 4)); + size_t old = pos.size(); + pos.resize(old + (size_t) m.vert_count * 3); + char *dst = reinterpret_cast(pos.data() + old); + m.for_pos([&](const char *b, size_t n) { + std::memcpy(dst, b, n); + dst += n; + }); } }; if (meshopt && glb_write_framed_meshopt(path, hdrs, provide_idx, provide_pos, extras, ada_ext)) @@ -713,19 +796,16 @@ inline bool write_glb_merged(const std::string &path, const std::vectormats().end()) continue; const auto &m = it->second; - std::ifstream f(m.idx_path, std::ios::binary); - // Re-offset in bulk (a buffer at a time) instead of per-uint32 read/add/write — - // there are tens of millions of indices, so the syscall + stream overhead dominates. - std::vector ibuf(1u << 16); - while (f) { - f.read(reinterpret_cast(ibuf.data()), (std::streamsize) (ibuf.size() * 4)); - size_t got = (size_t) (f.gcount() / 4); - if (!got) - break; + // Re-offset in bulk (a chunk at a time) instead of per-uint32 read/add/write — there are + // tens of millions of indices, so per-element overhead dominates. Chunks come from the + // material's RAM buffer or its spill file (for_idx hides which). + std::vector ibuf; + m.for_idx([&](const uint32_t *rb, size_t got) { + ibuf.assign(rb, rb + got); for (size_t i = 0; i < got; ++i) ibuf[i] += base; out.write(reinterpret_cast(ibuf.data()), (std::streamsize) (got * 4)); - } + }); base += m.vert_count; idx_bytes += m.index_count * 4; } @@ -737,8 +817,7 @@ inline bool write_glb_merged(const std::string &path, const std::vectormats().end()) continue; const auto &m = it->second; - std::ifstream f(m.pos_path, std::ios::binary); - out << f.rdbuf(); + m.for_pos([&](const char *b, size_t n) { out.write(b, (std::streamsize) n); }); pos_bytes += m.vert_count * 12; } out.write(z, pad4(pos_bytes)); From 237a7d53bc6175c8e27bf92cba717ff6c7928f91 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 10:15:00 +0200 Subject: [PATCH 56/65] fix: IFC linear placement + opening voids in the native reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two placement/relationship gaps in IfcResolver that made corpus IFC4x3 files render wrong: - IfcLinearPlacement (linear referencing along an alignment) was ignored — object_placement only handled IfcLocalPlacement, so every product on a linear placement collapsed to the origin (all geoms stacked). Consume the optional CartesianPosition (arg 2) — the exporter's pre-computed cartesian frame equivalent — which places them correctly (verified: the 25 signal products in linear-placement-of-signal.ifc now spread ~933 m along the alignment instead of at 0,0,0). Full IfcPointByDistanceExpression evaluation (no CartesianPosition) is a follow-up; identity there keeps prior behaviour. - IfcRelVoidsElement (openings/recesses) was never applied: the host solid wasn't cut and the IfcOpeningElement was rendered as a standalone positive solid. Build a voids map in build_rel_maps, exclude opening elements from proxy_roots, and in resolve_product subtract each opening's solid from the host via the existing BooleanN(difference) path — moving the opening into the host's local frame first (inverse(host placement) * opening placement). Frame-placed openings (extrude/revolve/sweep) + nested booleans are cut; face-set openings are skipped (no single frame). Verified on slab-openings.ifc: 1 slab with its opening + recess cut (was slab + 2 solid boxes), matching ifcopenshell's object count. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_reader.h | 163 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index b55c549..9bb8790 100644 --- a/src/cad/ifc_reader.h +++ b/src/cad/ifc_reader.h @@ -54,6 +54,7 @@ class IfcResolver { // subtype) refs an IfcProductDefinitionShape. Resolving by representation — instead of the // old cheap name allowlist, which missed subtypes like IfcSanitaryTerminal / MEP / furniture // terminals — catches every product the ifcopenshell reader would, with no schema. + build_rel_maps(); // populate openings_ so opening elements are excluded (they cut their host) std::unordered_set pds; // IfcProductDefinitionShape ids std::string scratch; for (long id : idx_.ids) @@ -61,6 +62,8 @@ class IfcResolver { pds.insert(id); std::vector roots; for (long id : idx_.ids) { + if (openings_.count(id)) + continue; // an IfcOpeningElement — subtracted from its host, not a standalone product std::string_view t = type_of(id, scratch); if (is_product_type(t)) { // fast path for the common structural types (no full parse) roots.push_back(id); @@ -290,6 +293,17 @@ class IfcResolver { for (const Value &e : in->args[5].items) if (e.is_ref()) parent_of_[e.i] = parent; + } else if (iequals(t, "IFCRELVOIDSELEMENT")) { + // (…, RelatingBuildingElement=arg 4, RelatedOpeningElement=arg 5): the opening is + // subtracted from the host element's solid (a hole/recess) and never rendered on its own. + const Instance *in = inst(id); + if (!in || in->args.size() < 6) + continue; + long host = ref_arg(*in, 4), opening = ref_arg(*in, 5); + if (host > 0 && opening > 0) { + voids_[host].push_back(opening); + openings_.insert(opening); + } } } } @@ -424,6 +438,44 @@ class IfcResolver { root.revolve = nullptr; root.boolean = nullptr; } + // IfcRelVoidsElement: subtract each opening element's solid (a hole/recess) from this host. The + // opening geometry is in the OPENING element's local frame; move it into THIS host's local frame + // (inverse(host placement) * opening placement) before the difference — the host's placement is + // applied to the whole result below. Face-set openings (no single frame to move) are skipped. + if (auto vit = voids_.find(pid); vit != voids_.end()) { + SolidItemN base = root_to_solid(root); + if (solid_ok(base)) { + std::array host_inv = rigid_inverse(object_placement(ref_arg(*p, 5))); + SolidItemN acc = base; + int cuts = 0; + for (long opid : vit->second) { + const Instance *op = inst(opid); + long bid = op ? first_body_item(opid) : 0; + if (!bid) + continue; + SolidItemN cut = resolve_solid_item(bid); + if (!solid_ok(cut)) + continue; + std::array rel = mat_mul(host_inv, object_placement(ref_arg(*op, 5))); + if (!xform_solid_item(cut, rel)) + continue; + auto bn = std::make_shared(); + bn->op = 0; // difference + bn->a = acc; + bn->b = cut; + acc = SolidItemN{}; + acc.boolean = bn; + ++cuts; + } + if (cuts > 0) { + root.faces.clear(); + root.extrusion = nullptr; + root.revolve = nullptr; + root.sweep = nullptr; + root.boolean = acc.boolean; + } + } + } // Compose the product's world placement (IfcLocalPlacement chain) onto every instance — the // geometry rep is in the element's local frame; ObjectPlacement positions it in the world. if (!root.faces.empty() || root.extrusion || root.revolve || root.boolean) { @@ -487,6 +539,8 @@ class IfcResolver { colour_map_built_ = m.colour_map_built_; contained_of_ = m.contained_of_; parent_of_ = m.parent_of_; + voids_ = m.voids_; + openings_ = m.openings_; rel_maps_built_ = m.rel_maps_built_; angle_scale_ = m.angle_scale_; } @@ -551,6 +605,8 @@ class IfcResolver { bool colour_map_built_ = false; std::unordered_map contained_of_; // product -> containing spatial element (IfcRelContained…) std::unordered_map parent_of_; // child -> aggregating parent (IfcRelAggregates) + std::unordered_map> voids_; // host element -> opening elements (IfcRelVoidsElement) + std::unordered_set openings_; // opening element ids (cut from host, not rendered standalone) bool rel_maps_built_ = false; const Instance *inst(long id) { @@ -2174,6 +2230,90 @@ class IfcResolver { } return R; } + // ---- rigid-transform helpers for IfcRelVoidsElement (move an opening into its host's frame) ---- + // Inverse of a rigid 4x4 (column-major): [R|t] -> [R^T | -R^T t]. + static std::array rigid_inverse(const std::array &M) { + std::array r{}; + r[0] = M[0]; r[1] = M[4]; r[2] = M[8]; // R^T columns + r[4] = M[1]; r[5] = M[5]; r[6] = M[9]; + r[8] = M[2]; r[9] = M[6]; r[10] = M[10]; + float tx = M[12], ty = M[13], tz = M[14]; + r[12] = -(M[0] * tx + M[1] * ty + M[2] * tz); // -R^T t + r[13] = -(M[4] * tx + M[5] * ty + M[6] * tz); + r[14] = -(M[8] * tx + M[9] * ty + M[10] * tz); + r[15] = 1.0f; + return r; + } + static Vec3 xform_dir(const std::array &M, const Vec3 &v) { + return {M[0] * v.x + M[4] * v.y + M[8] * v.z, M[1] * v.x + M[5] * v.y + M[9] * v.z, + M[2] * v.x + M[6] * v.y + M[10] * v.z}; + } + static Vec3 xform_pt(const std::array &M, const Vec3 &p) { + Vec3 r = xform_dir(M, p); + return {r.x + M[12], r.y + M[13], r.z + M[14]}; + } + static void xform_frame(Frame &f, const std::array &M) { + f.o = xform_pt(M, f.o); + f.x = xform_dir(M, f.x); + f.y = xform_dir(M, f.y); + f.z = xform_dir(M, f.z); + } + // Move a solid operand into a new coordinate frame by transforming its placement frame(s). Handles + // the frame-placed solids (extrude/revolve/sweep) + nested booleans; returns false for face-set + // operands (no single frame to move) so the caller skips that cut rather than misplace it. + bool xform_solid_item(SolidItemN &it, const std::array &M) { + if (it.extrusion) { + xform_frame(it.extrusion->frame, M); + return true; + } + if (it.revolve) { + xform_frame(it.revolve->frame, M); + it.revolve->axis_origin = xform_pt(M, it.revolve->axis_origin); + it.revolve->axis_dir = xform_dir(M, it.revolve->axis_dir); + return true; + } + if (it.sweep) { + xform_frame(it.sweep->frame, M); + return true; + } + if (it.boolean) + return xform_solid_item(it.boolean->a, M) && xform_solid_item(it.boolean->b, M); + return false; // face-set operand — no single frame; skip this cut + } + // A resolved root's geometry as a boolean operand (share the shared_ptrs — the caller replaces root). + static SolidItemN root_to_solid(const NgeomRoot &r) { + SolidItemN s; + s.extrusion = r.extrusion; + s.revolve = r.revolve; + s.sweep = r.sweep; + s.boolean = r.boolean; + s.faces = r.faces; + return s; + } + // First 3D body/solid rep-item id of an element (skips 2D FootPrint/Profile/… and Axis reps). 0 if none. + long first_body_item(long pid) { + const Instance *p = inst(pid); + if (!p) + return 0; + const Instance *pds = inst(ref_arg(*p, 6)); + if (!pds || pds->args.size() < 3) + return 0; + for (const Value &srref : pds->args[2].items) { + const Instance *sr = inst(srref.i); + if (!sr || sr->args.size() < 4) + continue; + std::string_view id = (sr->args.size() > 1 && sr->args[1].kind == adacpp::step::Kind::Str) + ? sr->args[1].s + : std::string_view{}; + if (id == "FootPrint" || id == "Annotation" || id == "Profile" || id == "Plan" || id == "Box" || + id == "Axis") + continue; + for (const Value &item : sr->args[3].items) + if (item.is_ref()) + return item.i; + } + return 0; + } static bool is_identity(const std::array &M) { static const std::array I = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; for (int i = 0; i < 16; ++i) @@ -2194,13 +2334,30 @@ class IfcResolver { return {(float) f.x.x, (float) f.x.y, (float) f.x.z, 0.0f, (float) f.y.x, (float) f.y.y, (float) f.y.z, 0.0f, (float) f.z.x, (float) f.z.y, (float) f.z.z, 0.0f, (float) f.o.x, (float) f.o.y, (float) f.o.z, 1.0f}; } - // IfcLocalPlacement(PlacementRelTo, RelativePlacement) -> world matrix (recurse up the chain). + // Object placement -> world matrix. Handles the IfcLocalPlacement chain and IfcLinearPlacement + // (IFC4x3 linear referencing along an alignment). Both recurse up PlacementRelTo (arg 0). std::array object_placement(long id, int depth = 0) { static const std::array I = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; const Instance *in = inst(id); - if (!in || depth > 64 || !iequals(in->type, "IFCLOCALPLACEMENT")) + if (!in || depth > 64) return I; - std::array rel = (in->args.size() > 1 && in->args[1].is_ref()) ? axis2_mat(in->args[1].i) : I; + std::array rel = I; + if (iequals(in->type, "IFCLOCALPLACEMENT")) { + // (PlacementRelTo, RelativePlacement=IfcAxis2Placement3D) + if (in->args.size() > 1 && in->args[1].is_ref()) + rel = axis2_mat(in->args[1].i); + } else if (iequals(in->type, "IFCLINEARPLACEMENT")) { + // (PlacementRelTo, RelativePlacement=IfcAxis2PlacementLinear, CartesianPosition=IfcAxis2Placement3D?). + // Placing an object by distance-along-the-alignment needs to evaluate the basis curve; exporters + // provide the equivalent cartesian frame in the optional CartesianPosition (arg 2) precisely so + // readers that don't do linear referencing can still place the object. Prefer it — without it the + // object collapsed to the origin (all geoms stacked at 0,0,0). Full IfcPointByDistanceExpression + // evaluation (no CartesianPosition) is a follow-up; identity there keeps prior behaviour. + if (in->args.size() > 2 && in->args[2].is_ref()) + rel = axis2_mat(in->args[2].i); + } else { + return I; // grid placement etc. — unhandled, keep identity + } if (in->args.size() > 0 && in->args[0].is_ref()) return mat_mul(object_placement(in->args[0].i, depth + 1), rel); return rel; From 50e81481153d6e089889cb8d841d216c786721e5 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 10:25:18 +0200 Subject: [PATCH 57/65] fix: ignore bogus large SI unit prefixes in the native STEP reader (empty-scene tanks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage_Tanks_*.stp declare SI_UNIT(.EXA.,.METRE.) — exa-metre — as their length unit. The reader honoured it, scaling sane ~258 m coordinates up to ~2.5e20 m, so the geometry landed astronomically far from the origin and the viewer showed an empty scene. No real CAD file uses a base unit of MEGA-metre or larger; the Python stream reader already only recognises micro..kilo (_SI_PREFIX_SCALE) and defaults everything else to 1.0, which is why the Python serializer renders these files fine. Match it: si_prefix_factor now handles only KILO/DECI/CENTI/MILLI/MICRO/NANO and treats any other prefix as unprefixed metres. Verified: Storage_Tanks_01.stp bbox center 2.5e20 -> 257.5 m, extent -> ~10-33 m (a tank farm), 5975 solids; all four storage-tank files share the same unit and are fixed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cadit/step/step_reader.h | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index f05480c..ada3721 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -1680,23 +1680,14 @@ class Resolver { } // --- length unit -> metres (LENGTH_UNIT / SI_UNIT) ------------------------------------ + // Only the prefixes real CAD length units use (micro..kilo), matching the Python stream reader's + // _SI_PREFIX_SCALE. The large SI prefixes (MEGA..EXA) are never a genuine CAD base unit — some + // exporters emit a bogus one (e.g. Storage_Tanks_01.stp declares SI_UNIT(.EXA.,.METRE.), which + // would blow ~258 m coordinates up to 2.5e20 m -> off-screen "empty scene"), so an unrecognized + // prefix falls through to 1.0 (treat as metres) exactly like the Python reader does. static double si_prefix_factor(std::string_view p) { - if (p == "EXA") - return 1e18; - if (p == "PETA") - return 1e15; - if (p == "TERA") - return 1e12; - if (p == "GIGA") - return 1e9; - if (p == "MEGA") - return 1e6; if (p == "KILO") return 1e3; - if (p == "HECTO") - return 1e2; - if (p == "DECA") - return 1e1; if (p == "DECI") return 1e-1; if (p == "CENTI") @@ -1707,9 +1698,7 @@ class Resolver { return 1e-6; if (p == "NANO") return 1e-9; - if (p == "PICO") - return 1e-12; - return 1.0; + return 1.0; // unprefixed / uncommon-or-bogus prefix -> metres (matches the Python reader) } // SI_UNIT args: (prefix?, .METRE.). Returns the scale to metres, or <0 if not a length unit. static double si_length_scale(const std::vector &a) { From 3baa1feedcae1cbe55a5340a9b5aeaf155abefd7 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 11:42:18 +0200 Subject: [PATCH 58/65] feat: count silently-dropped faces in tessellation (audit health flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A face that carries a real trim boundary but tessellates to zero triangles is dropped geometry — the class of bug that only shows up on visual inspection (e.g. Ventilator.stp's 13 SURFACE_OF_LINEAR_EXTRUSION faces produce 0 triangles while the 189 B-splines are fine). Add always-on thread-safe counters (tess_dropped_faces / tess_total_faces) incremented in tessellate_face on the emitted-triangle delta (not `ok` — some failures still return true with an empty result). The STEP->GLB / STEP->mesh / IFC->GLB streaming entry points reset them per conversion and, when anything dropped, emit a [GEOMHEALTH-JSON] {dropped_faces, total_faces} line for the worker to fold into the audit (convert_meta.geom_health). Quiet on clean conversions. Verified: Ventilator emits dropped_faces=13/305; ngeom suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/ifc_to_glb_stream.h | 4 ++ src/cad/step_to_glb_stream.h | 8 ++++ src/cad/step_to_mesh_stream.h | 4 ++ src/geom/neutral/ngeom_tessellate.cpp | 59 ++++++++++++++++++--------- src/geom/neutral/ngeom_tessellate.h | 8 ++++ 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/cad/ifc_to_glb_stream.h b/src/cad/ifc_to_glb_stream.h index f3e6100..864e458 100644 --- a/src/cad/ifc_to_glb_stream.h +++ b/src/cad/ifc_to_glb_stream.h @@ -39,6 +39,7 @@ inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out double model_scale = 0.0, int num_threads = 0) { using namespace adacpp::ngeom; adacpp::tune_malloc_for_streaming(); + adacpp::ngeom::reset_tess_face_stats(); // count dropped faces (audit health flag) adacpp::step::StreamIndex idx = adacpp::step::StreamIndex::from_file(in_path); if (!idx.ok()) return -1; @@ -189,6 +190,9 @@ inline long stream_ifc_to_glb(const std::string &in_path, const std::string &out } if (remove_after) ::rmdir(spill.c_str()); + if (std::uint64_t dropped = adacpp::ngeom::tess_dropped_faces()) + std::fprintf(stderr, "[GEOMHEALTH-JSON] {\"dropped_faces\":%llu,\"total_faces\":%llu}\n", + (unsigned long long) dropped, (unsigned long long) adacpp::ngeom::tess_total_faces()); return ok ? nwritten.load() : -1; } diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 9b8d8e5..22750c5 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -46,6 +48,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou using namespace adacpp::ngeom; adacpp::prof::StepProfiler prof("stream_step_to_glb"); adacpp::tune_malloc_for_streaming(); // bound streaming peak RSS (mmap/trim tuning) before the pool + adacpp::ngeom::reset_tess_face_stats(); // count dropped faces across this conversion (audit health flag) // File-backed offset index: mmap to scan (freed-behind), then pread each statement on demand so // the file lives in the OS page cache, not process RSS. @@ -263,6 +266,11 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou ::rmdir(spill.c_str()); } prof.note("threads", nthreads); + // Emit a health line the worker folds into the audit (convert_meta) so silently-dropped faces are + // flagged without visual inspection. Only when something dropped — keep the common case quiet. + if (std::uint64_t dropped = adacpp::ngeom::tess_dropped_faces()) + std::fprintf(stderr, "[GEOMHEALTH-JSON] {\"dropped_faces\":%llu,\"total_faces\":%llu}\n", + (unsigned long long) dropped, (unsigned long long) adacpp::ngeom::tess_total_faces()); return ok ? nwritten.load() : -1; } diff --git a/src/cad/step_to_mesh_stream.h b/src/cad/step_to_mesh_stream.h index d1d4ab9..cdf941f 100644 --- a/src/cad/step_to_mesh_stream.h +++ b/src/cad/step_to_mesh_stream.h @@ -163,6 +163,7 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o static const std::array kIdentity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; adacpp::prof::StepProfiler prof("stream_step_to_mesh"); adacpp::tune_malloc_for_streaming(); // bound streaming peak RSS (mmap/trim tuning) before the pool + adacpp::ngeom::reset_tess_face_stats(); // count dropped faces (audit health flag) adacpp::step::StreamIndex idx = adacpp::step::StreamIndex::from_file(in_path); if (!idx.ok()) @@ -372,6 +373,9 @@ inline long stream_step_to_mesh(const std::string &in_path, const std::string &o if (remove_after) ::rmdir(spill.c_str()); prof.note("threads", nthreads); + if (std::uint64_t dropped = adacpp::ngeom::tess_dropped_faces()) + std::fprintf(stderr, "[GEOMHEALTH-JSON] {\"dropped_faces\":%llu,\"total_faces\":%llu}\n", + (unsigned long long) dropped, (unsigned long long) adacpp::ngeom::tess_total_faces()); return ok ? (long) total : -1; } diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 607adb0..402c280 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1501,26 +1501,47 @@ bool tessellate_face_impl(const FaceSurfaceN &face, const TessParams &tp, TessMe } } // namespace +// Per-conversion dropped/total face counters (see ngeom_tessellate.h). Atomic so the face + root pools +// increment safely; reset once single-threaded before a conversion, read after the pools join. +static std::atomic g_dropped_faces{0}; +static std::atomic g_total_faces{0}; +std::uint64_t tess_dropped_faces() { return g_dropped_faces.load(std::memory_order_relaxed); } +std::uint64_t tess_total_faces() { return g_total_faces.load(std::memory_order_relaxed); } +void reset_tess_face_stats() { + g_dropped_faces.store(0, std::memory_order_relaxed); + g_total_faces.store(0, std::memory_order_relaxed); +} + bool tessellate_face(const FaceSurfaceN &face, const TessParams &tp, TessMesh &outm) { - if (!FDBG && !TESSDBG) - return tessellate_face_impl(face, tp, outm); - size_t i0 = outm.indices.size(); - size_t bpts = 0; - for (const FaceBoundN &b : face.bounds) - if (b.loop) - bpts += b.loop->discretize(tp.deflection, tp.max_angle).size(); - if (TESSDBG && face.surface) { - std::fprintf(stderr, "TESSDBG FACE surf=%s", surf_kind(*face.surface)); - log_surf_params(*face.surface); - std::fprintf(stderr, " bounds=%zu bpts=%zu same_sense=%d\n", face.bounds.size(), bpts, (int) face.same_sense); - } - bool ok = tessellate_face_impl(face, tp, outm); - if (FDBG) - std::fprintf(stderr, "FDBG FACE %-11s bounds=%zu bpts=%zu tris=%zu ok=%d\n", - face.surface ? surf_kind(*face.surface) : "?", face.bounds.size(), bpts, - (outm.indices.size() - i0) / 3, (int) ok); - if (TESSDBG) - std::fprintf(stderr, "TESSDBG FACE-DONE tris=%zu ok=%d\n", (outm.indices.size() - i0) / 3, (int) ok); + const size_t i0 = outm.indices.size(); + bool ok; + if (!FDBG && !TESSDBG) { + ok = tessellate_face_impl(face, tp, outm); + } else { + size_t bpts = 0; + for (const FaceBoundN &b : face.bounds) + if (b.loop) + bpts += b.loop->discretize(tp.deflection, tp.max_angle).size(); + if (TESSDBG && face.surface) { + std::fprintf(stderr, "TESSDBG FACE surf=%s", surf_kind(*face.surface)); + log_surf_params(*face.surface); + std::fprintf(stderr, " bounds=%zu bpts=%zu same_sense=%d\n", face.bounds.size(), bpts, + (int) face.same_sense); + } + ok = tessellate_face_impl(face, tp, outm); + if (FDBG) + std::fprintf(stderr, "FDBG FACE %-11s bounds=%zu bpts=%zu tris=%zu ok=%d\n", + face.surface ? surf_kind(*face.surface) : "?", face.bounds.size(), bpts, + (outm.indices.size() - i0) / 3, (int) ok); + if (TESSDBG) + std::fprintf(stderr, "TESSDBG FACE-DONE tris=%zu ok=%d\n", (outm.indices.size() - i0) / 3, (int) ok); + } + // Health accounting (always on): a face carrying a real trim boundary that yields NO triangles is + // silently dropped geometry — count it. Uses the emitted-triangle delta, not `ok`, since some + // failures still return true with an empty result. + g_total_faces.fetch_add(1, std::memory_order_relaxed); + if (outm.indices.size() == i0 && !face.bounds.empty()) + g_dropped_faces.fetch_add(1, std::memory_order_relaxed); return ok; } diff --git a/src/geom/neutral/ngeom_tessellate.h b/src/geom/neutral/ngeom_tessellate.h index dfd64c9..4ae42f2 100644 --- a/src/geom/neutral/ngeom_tessellate.h +++ b/src/geom/neutral/ngeom_tessellate.h @@ -55,6 +55,14 @@ struct TessMesh { // Tessellate one neutral face, appending into `out`. Returns true if it produced triangles. bool tessellate_face(const FaceSurfaceN &face, const TessParams &tp, TessMesh &out); +// Per-conversion tessellation-health counters (thread-safe across the face/root pools): a face that +// carries a real trim boundary but fails to produce triangles is silently DROPPED geometry — the class +// of bug that otherwise only shows up on visual inspection. Reset before a conversion, read after, so +// the streaming entry points can report a dropped-face count for the audit to flag. +std::uint64_t tess_dropped_faces(); +std::uint64_t tess_total_faces(); +void reset_tess_face_stats(); + // Tessellate a whole decoded document (all roots), one TessMesh with a Group per root. TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp); From eceedbdcecd4aed6a3ec46c72970d0edaba1b8bd Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 12:22:15 +0200 Subject: [PATCH 59/65] fix: tessellate SURFACE_OF_LINEAR_EXTRUSION faces (dropped ventilator ridge faces) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinearExtrusionSurface::uv inverted 3D boundary points with the generic 2D newton_uv, whose coarse 12x12 grid seed aliases against a wiggly B-spline profile (seeding Newton onto the wrong fold), AND it clamped v to [0, depth]. A STEP SURFACE_OF_LINEAR_EXTRUSION is semi-infinite along its axis — the VECTOR magnitude (=depth) is only a parameter scale, the real extent is set by the trimming face bounds. The clamp pinned every boundary point past v=depth onto the v=depth line, collapsing the UV loop so tess2 produced no triangles and the face was silently dropped. Replace with the exact separable inversion: point(u,v) = profile(u) + dir*v, so v = dir.(p - profile(u)) in closed form and u minimizes the residual perpendicular to dir — a 1D search over the profile's own parameter, densely seeded at the curve's sampling density. On the ventilator this recovers 13 dropped extrusion faces (GEOMHEALTH dropped_faces 13 -> 0); all NGEOM suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_bspline.h | 54 ++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/geom/neutral/ngeom_bspline.h b/src/geom/neutral/ngeom_bspline.h index 1f73ad2..808cb66 100644 --- a/src/geom/neutral/ngeom_bspline.h +++ b/src/geom/neutral/ngeom_bspline.h @@ -454,10 +454,60 @@ struct LinearExtrusionSurface : Surface { Vec3 normal(double u, double v) const override { return profile->tangent(u).cross(dir).normalized(); } - bool uv(const Vec3 &p, double uhint, double vhint, double &u, double &v) const override { + bool uv(const Vec3 &p, double uhint, double, double &u, double &v) const override { double umin, umax, vmin, vmax; domain(umin, umax, vmin, vmax); - return newton_uv(*this, p, umin, umax, vmin, vmax, uhint, vhint, u, v); + // Separable inversion: point(u,v) = profile(u) + dir*v, so for any u the optimal v is the + // projection dir.(p - profile(u)) and u alone minimizes the residual PERPENDICULAR to dir. + // Search that 1D problem over the profile's OWN parameter instead of the generic 2D grid + // (newton_uv): a coarse uniform 12x12 grid aliases against a wiggly B-spline profile domain + // (e.g. knots ~[45.9,71.3]) and seeds Newton onto the wrong fold -> garbled UV loop -> tess2 + // finds no triangles. A dense profile-parameter seed at the curve's own sampling density, + // then a 1D Newton on the perpendicular distance, converges to the correct u. + auto perp2 = [&](double uu) { + Vec3 d = profile->point(uu) - p; + double along = dir.dot(d); + Vec3 perp = d - dir * along; + return perp.dot(perp); + }; + int nseed = profile->uniform_edge_segments(); + if (nseed <= 0) + nseed = std::max(32, profile->discretize_spans(umin, umax) * 4); + nseed = nseed < 32 ? 32 : (nseed > 2048 ? 2048 : nseed); + double useed = (std::isfinite(uhint) && uhint >= umin && uhint <= umax) ? uhint : umin; + double best = perp2(useed); + for (int i = 0; i <= nseed; ++i) { + double uu = umin + (umax - umin) * i / nseed; + double g = perp2(uu); + if (g < best) { + best = g; + useed = uu; + } + } + u = useed; + double h = (umax - umin) * 1e-6 + 1e-12; + for (int it = 0; it < 40; ++it) { + double gm = perp2(u - h), gp = perp2(u + h), g0 = perp2(u); + double g1 = (gp - gm) * (0.5 / h); // g'(u) + double g2 = (gp - 2.0 * g0 + gm) / (h * h); // g''(u) + if (!(std::abs(g2) > 1e-300)) + break; + double un = clampd(u - g1 / g2, umin, umax); + if (std::abs(un - u) < (umax - umin) * 1e-12) + break; + u = un; + } + // v is NOT clamped to [0, depth]: a STEP SURFACE_OF_LINEAR_EXTRUSION is semi-infinite along + // its axis — the VECTOR magnitude (-> depth) is only a parameter scale, the real extent is set + // by the trimming face bounds. Clamping to depth pins every boundary point past v=depth onto + // the v=depth line, collapsing the UV loop (residual == |true_v - depth|) and dropping the face. + (void) vmin; + (void) vmax; + v = dir.dot(p - profile->point(u)); + // relative tolerance: the 3D boundary point is chord-discretized off the analytic surface by + // up to ~deflection, so a strict abs check would spuriously reject good inversions on a curved + // profile. A truly-off point (wrong fold / clamped v) misses by O(depth), far above this. + return (point(u, v) - p).norm() < 1e-4 * std::max(1.0, approx_size()); } // density floor along the profile (Rust geom.rs Surface::u_step Extrusion arm); v (along dir) // is ruled -> INFINITY (base default), matching Rust's `_ => INFINITY` for extrusion v_step. From 826e34768c3a25231003e4d6d418d4f791523269 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 12:32:13 +0200 Subject: [PATCH 60/65] fix: keep extrusion UV inversion on one profile fold (hint-first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The separable extrusion inverter rescanned the whole profile domain for every boundary point, so adjacent points on a self-approaching profile could snap to different folds — a self-intersecting UV loop that tess2 (winding-odd) fills beyond the true face (triangles extending past its limits). Try the previous point's parameter as the Newton seed first and accept it when it converges; only fall back to the global nearest-fold rescan when there is no usable hint or the hint doesn't converge. Neutral on the ventilator (profile doesn't self-approach); removes the branch-jump artifact on profiles that do. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_bspline.h | 71 ++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/geom/neutral/ngeom_bspline.h b/src/geom/neutral/ngeom_bspline.h index 808cb66..67c9cb9 100644 --- a/src/geom/neutral/ngeom_bspline.h +++ b/src/geom/neutral/ngeom_bspline.h @@ -464,50 +464,69 @@ struct LinearExtrusionSurface : Surface { // (e.g. knots ~[45.9,71.3]) and seeds Newton onto the wrong fold -> garbled UV loop -> tess2 // finds no triangles. A dense profile-parameter seed at the curve's own sampling density, // then a 1D Newton on the perpendicular distance, converges to the correct u. + // squared distance from p to the profile PERPENDICULAR to dir, as a function of profile param auto perp2 = [&](double uu) { Vec3 d = profile->point(uu) - p; double along = dir.dot(d); Vec3 perp = d - dir * along; return perp.dot(perp); }; + double span = umax - umin; + double h = span * 1e-6 + 1e-12; + // damped 1D Newton on perp2(u) from a seed, clamped to the profile domain + auto refine = [&](double useed) { + double uu = clampd(useed, umin, umax); + for (int it = 0; it < 40; ++it) { + double gm = perp2(uu - h), gp = perp2(uu + h), g0 = perp2(uu); + double g1 = (gp - gm) * (0.5 / h); // g'(u) + double g2 = (gp - 2.0 * g0 + gm) / (h * h); // g''(u) + if (!(std::abs(g2) > 1e-300)) + break; + double un = clampd(uu - g1 / g2, umin, umax); + if (std::abs(un - uu) < span * 1e-12) + break; + uu = un; + } + return uu; + }; + // v is NOT clamped to [0, depth]: a STEP SURFACE_OF_LINEAR_EXTRUSION is semi-infinite along + // its axis — the VECTOR magnitude (-> depth) is only a parameter scale, the real extent is set + // by the trimming face bounds. Clamping to depth pins every boundary point past v=depth onto + // the v=depth line, collapsing the UV loop (residual == |true_v - depth|) and dropping the face. + (void) vmin; + (void) vmax; + const double tol = 1e-4 * std::max(1.0, approx_size()); + auto accept = [&](double uu) { + u = uu; + v = dir.dot(p - profile->point(uu)); + return (point(u, v) - p).norm(); + }; + // Hint first: follow the previous boundary point's branch so consecutive points stay on the + // same fold of a self-approaching profile. A global rescan per point would let adjacent points + // snap to different folds, producing a self-intersecting UV loop that tess2 fills BEYOND the + // true face (triangles extending past its limits). Only rescan when there is no usable hint or + // the hint doesn't converge (residual > tol), then take the globally-nearest fold. + if (std::isfinite(uhint) && uhint >= umin && uhint <= umax) { + if (accept(refine(uhint)) < tol) + return true; + } int nseed = profile->uniform_edge_segments(); if (nseed <= 0) nseed = std::max(32, profile->discretize_spans(umin, umax) * 4); nseed = nseed < 32 ? 32 : (nseed > 2048 ? 2048 : nseed); - double useed = (std::isfinite(uhint) && uhint >= umin && uhint <= umax) ? uhint : umin; - double best = perp2(useed); - for (int i = 0; i <= nseed; ++i) { - double uu = umin + (umax - umin) * i / nseed; + double useed = umin, best = perp2(umin); + for (int i = 1; i <= nseed; ++i) { + double uu = umin + span * i / nseed; double g = perp2(uu); if (g < best) { best = g; useed = uu; } } - u = useed; - double h = (umax - umin) * 1e-6 + 1e-12; - for (int it = 0; it < 40; ++it) { - double gm = perp2(u - h), gp = perp2(u + h), g0 = perp2(u); - double g1 = (gp - gm) * (0.5 / h); // g'(u) - double g2 = (gp - 2.0 * g0 + gm) / (h * h); // g''(u) - if (!(std::abs(g2) > 1e-300)) - break; - double un = clampd(u - g1 / g2, umin, umax); - if (std::abs(un - u) < (umax - umin) * 1e-12) - break; - u = un; - } - // v is NOT clamped to [0, depth]: a STEP SURFACE_OF_LINEAR_EXTRUSION is semi-infinite along - // its axis — the VECTOR magnitude (-> depth) is only a parameter scale, the real extent is set - // by the trimming face bounds. Clamping to depth pins every boundary point past v=depth onto - // the v=depth line, collapsing the UV loop (residual == |true_v - depth|) and dropping the face. - (void) vmin; - (void) vmax; - v = dir.dot(p - profile->point(u)); // relative tolerance: the 3D boundary point is chord-discretized off the analytic surface by // up to ~deflection, so a strict abs check would spuriously reject good inversions on a curved - // profile. A truly-off point (wrong fold / clamped v) misses by O(depth), far above this. - return (point(u, v) - p).norm() < 1e-4 * std::max(1.0, approx_size()); + // profile. A truly-off point (wrong fold) misses by O(profile self-approach gap), well above. + return accept(refine(useed)) < tol; } // density floor along the profile (Rust geom.rs Surface::u_step Extrusion arm); v (along dir) // is ruled -> INFINITY (base default), matching Rust's `_ => INFINITY` for extrusion v_step. From dc41923bd9ab2ca6b8ad954cc7a2a847dcb7c25f Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 13:15:24 +0200 Subject: [PATCH 61/65] feat: opt-in per-face clickable regions in STEP->GLB (face_ranges_node) Capture per-face triangle ranges during tessellation (TessParams.capture_face_ranges) and emit them into scenes[0].extras as face_ranges_node: {solid_nid:[[start,len, face_id,seq],...]}, mirroring draw_ranges_node one level finer. start/len are relative to the solid's draw range; face_id is the STEP ADVANCED_FACE/FACE_SURFACE entity id (threaded via FaceSurfaceN.src_id); seq is the 0-based face position. Ranges are captured pre-weld (weld preserves index order/count) and survive meshopt (lossless codec), so they stay valid in the final GLB. Opt-in (STP2GLB --face-regions, stream_step_to_glb face_regions arg) so normal GLBs are unchanged; capture forces serial face tessellation for stable face order. Validated on the ventilator: 305 regions tile the solid's draw range exactly, each with its real STEP #id. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/step_to_glb_stream.h | 7 ++- src/cadit/step/step_reader.h | 1 + src/geom/neutral/ngeom_glb.h | 63 ++++++++++++++++++++++++--- src/geom/neutral/ngeom_tessellate.cpp | 25 +++++++++-- src/geom/neutral/ngeom_tessellate.h | 14 ++++++ src/geom/neutral/ngeom_topology.h | 4 ++ src/stp2glb/main.cpp | 7 ++- 7 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 22750c5..4de62a8 100644 --- a/src/cad/step_to_glb_stream.h +++ b/src/cad/step_to_glb_stream.h @@ -44,7 +44,7 @@ namespace adacpp { // still cleaned up by GlbSpillWriter's destructor; only the user-supplied dir survives. inline long stream_step_to_glb(const std::string &in_path, const std::string &out_path, double deflection, double angular_deg, int num_threads, bool meshopt, const std::string &spill_dir = "", - double model_scale = 0.0) { + double model_scale = 0.0, bool face_regions = false) { using namespace adacpp::ngeom; adacpp::prof::StepProfiler prof("stream_step_to_glb"); adacpp::tune_malloc_for_streaming(); // bound streaming peak RSS (mmap/trim tuning) before the pool @@ -61,6 +61,7 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou tp.deflection = deflection; tp.max_angle = angular_deg * 3.14159265358979323846 / 180.0; tp.model_scale = model_scale; // >0 => adaptive per-surface density; the per-solid tpp copies inherit it + tp.capture_face_ranges = face_regions; // opt-in per-face clickable regions -> scenes[0].extras // Metadata (colour/transform/path maps) once; workers copy these read-only maps. adacpp::step::Resolver master(idx); @@ -159,6 +160,10 @@ inline long stream_step_to_glb(const std::string &in_path, const std::string &ou adacpp::glb::GlbSolid gs; gs.positions = std::move(tm.positions); gs.indices = std::move(tm.indices); + // Carry per-face clickable regions (relative to this solid's index buffer) when captured. + gs.face_ranges.reserve(tm.face_ranges.size()); + for (const auto &fr : tm.face_ranges) + gs.face_ranges.push_back({fr.first_index, fr.index_count, fr.face_id, fr.face_seq}); gs.color = {rr.cr, rr.cg, rr.cb, rr.ca}; // grey default when !has_color gs.transforms = rr.transforms; gs.id = rr.id; // fallback leaf name diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index ada3721..4aa3f35 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -1503,6 +1503,7 @@ class Resolver { if (c != face_cache_.end()) return c->second; auto f = std::make_shared(); + f->src_id = id; // STEP ADVANCED_FACE / FACE_SURFACE entity id -> per-face clickable region label const Instance *in = inst(id); if (in && (in->type == "ADVANCED_FACE" || in->type == "FACE_SURFACE") && in->args.size() >= 4) { if (in->args[1].kind == Kind::List) diff --git a/src/geom/neutral/ngeom_glb.h b/src/geom/neutral/ngeom_glb.h index 2067296..4be8102 100644 --- a/src/geom/neutral/ngeom_glb.h +++ b/src/geom/neutral/ngeom_glb.h @@ -29,11 +29,22 @@ namespace adacpp::glb { // One tessellated solid to place in the GLB: local-space mesh + colour + per-instance world // transforms (column-major / glTF order; empty => a single identity instance). +// One clickable face region within a solid — the triangle range RELATIVE to the solid's own index +// buffer (the viewer adds it to the solid's draw-range start), plus the source face id + sequence. +// Emitted into scenes[0].extras as face_ranges_node only when face-region capture is requested. +struct FaceSub { + uint32_t start = 0; // first index, relative to the solid's draw range + uint32_t length = 0; // index count (3 * triangles) + int64_t face_id = 0; // source entity id (STEP/IFC #id), 0 if unknown + uint32_t seq = 0; // 0-based face position within the solid +}; + struct GlbSolid { std::vector positions; // flat xyz (local) std::vector indices; std::array color{0.5f, 0.5f, 0.5f, 1.0f}; std::vector> transforms; + std::vector face_ranges; // per-face regions (relative to this solid); empty unless captured std::string id; // solid's own name (fallback leaf name) std::string product_name; // the solid's product name (the picking leaf name `gid`); "" => use id // Per-instance assembly path (root-first (rep_id, product_name) levels, last level = the solid's @@ -126,6 +137,7 @@ struct PartRange { std::string id; uint32_t start, length; std::vector> path; + std::vector faces; // per-face regions relative to [start,length); empty unless captured }; // Build the scenes[0].extras content: id_hierarchy {nid:[name,parent]} (the full STEP product tree — @@ -190,8 +202,42 @@ inline std::string build_scene_extras(const std::vector> } ranges << "}"; } + // Per-face clickable regions (opt-in): face_ranges_node {solid_nid:[[start,len,face_id,seq],...]} + // where start/len are RELATIVE to that solid's draw range (the viewer adds the draw-range start). + // Emitted only when some part carries face regions, so normal GLBs are unchanged. + std::ostringstream franges; + bool any_faces = false; + for (const auto &pm : per_mat) + for (const PartRange &r : pm) + if (!r.faces.empty()) { + any_faces = true; + break; + } + if (any_faces) { + for (size_t m = 0; m < per_mat.size(); ++m) { + franges << ",\"face_ranges_node" << m << "\":{"; + bool firstk = true; + for (size_t k = 0; k < per_mat[m].size(); ++k) { + const PartRange &r = per_mat[m][k]; + if (r.faces.empty()) + continue; + if (!firstk) + franges << ","; + firstk = false; + franges << "\"" << solid_nid[m][k] << "\":["; + for (size_t j = 0; j < r.faces.size(); ++j) { + const FaceSub &fs = r.faces[j]; + if (j) + franges << ","; + franges << "[" << fs.start << "," << fs.length << "," << fs.face_id << "," << fs.seq << "]"; + } + franges << "]"; + } + franges << "}"; + } + } std::ostringstream e; - e << "\"id_hierarchy\":{" << hier.str() << "}" << ranges.str(); + e << "\"id_hierarchy\":{" << hier.str() << "}" << ranges.str() << franges.str(); return e.str(); } @@ -476,9 +522,11 @@ inline bool write_glb(const std::string &path, const std::vector &soli m.idx.push_back(v); m.idx_max = std::max(m.idx_max, v); } - // One pickable leaf per placement (1:1 with the Python scene builder). + // One pickable leaf per placement (1:1 with the Python scene builder). Face regions are + // relative to the solid's index buffer, identical for every placement, so each leaf carries + // the same list (the viewer re-bases each against that leaf's own draw-range start). m.parts.push_back({instance_leaf_name(s, t), part_start, (uint32_t) m.idx.size() - part_start, - instance_parent_path(s, t)}); + instance_parent_path(s, t), s.face_ranges}); } } std::vector hdrs; @@ -549,6 +597,7 @@ class GlbSpillWriter { std::string id; uint32_t index_count; std::vector> path; + std::vector faces; // per-face regions relative to this part; empty unless captured }; std::vector parts; // per-solid (id, index_count, assembly path) in add order @@ -621,8 +670,10 @@ class GlbSpillWriter { m.idx_max = std::max(m.idx_max, v); ++m.index_count; } - // One pickable leaf per placement (1:1 with the Python scene builder). - m.parts.push_back({instance_leaf_name(s, t), m.index_count - inst_before, instance_parent_path(s, t)}); + // One pickable leaf per placement (1:1 with the Python scene builder). Face regions are + // relative to the solid's index buffer (same for every placement); each leaf keeps its own copy. + m.parts.push_back( + {instance_leaf_name(s, t), m.index_count - inst_before, instance_parent_path(s, t), s.face_ranges}); } } void flush() { @@ -739,7 +790,7 @@ inline bool write_glb_merged(const std::string &path, const std::vectormats().end()) continue; for (const auto &part : it->second.parts) { - parts.push_back({part.id, off, part.index_count, part.path}); + parts.push_back({part.id, off, part.index_count, part.path, part.faces}); off += part.index_count; } } diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index 402c280..e1c3305 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -1935,7 +1935,7 @@ static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, Tes // are independent (each tessellate_face builds its own libtess2 tessellator); // per-face local meshes merged in face order keep the output identical to // the serial loop. The 64-face floor keeps small solids on the cheap path. - if (tp.threads > 1 && root.faces.size() >= 64) { + if (tp.threads > 1 && root.faces.size() >= 64 && !tp.capture_face_ranges) { const size_t n = root.faces.size(); TessParams tpl = tp; tpl.threads = 1; // no nested pools inside a face @@ -1977,9 +1977,20 @@ static void tessellate_one_root(const NgeomRoot &root, const TessParams &tp, Tes } // locals freed here -> next batch's peak is one batch, not all n faces return; } - for (const auto &face : root.faces) - if (face) + for (size_t fi = 0; fi < root.faces.size(); ++fi) { + const auto &face = root.faces[fi]; + if (!face) + continue; + if (tp.capture_face_ranges) { + const uint32_t s = (uint32_t) out.indices.size(); + tessellate_face(*face, tp, out); + const uint32_t c = (uint32_t) out.indices.size() - s; + if (c > 0) + out.face_ranges.push_back({s, c, face->src_id, (uint32_t) fi}); + } else { tessellate_face(*face, tp, out); + } + } } } @@ -2009,6 +2020,10 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { uint32_t vfirst = (uint32_t) (mesh.positions.size() / 3); mesh.mesh_type = rm.mesh_type; // single-root streaming: propagate LINES/TRIANGLES append_mesh(mesh, rm); + for (auto fr : rm.face_ranges) { // re-base per-face ranges onto the merged index buffer + fr.first_index += first; + mesh.face_ranges.push_back(fr); + } mesh.groups.push_back({root.id, first, (uint32_t) mesh.indices.size() - first, vfirst, (uint32_t) (mesh.positions.size() / 3) - vfirst}); } @@ -2034,6 +2049,10 @@ TessMesh tessellate_doc(const NgeomDoc &doc, const TessParams &tp) { uint32_t first = (uint32_t) mesh.indices.size(); uint32_t vfirst = (uint32_t) (mesh.positions.size() / 3); append_mesh(mesh, locals[i]); + for (auto fr : locals[i].face_ranges) { // re-base per-face ranges onto the merged index buffer + fr.first_index += first; + mesh.face_ranges.push_back(fr); + } mesh.groups.push_back({roots[i].id, first, (uint32_t) mesh.indices.size() - first, vfirst, (uint32_t) (mesh.positions.size() / 3) - vfirst}); } diff --git a/src/geom/neutral/ngeom_tessellate.h b/src/geom/neutral/ngeom_tessellate.h index 4ae42f2..6182b82 100644 --- a/src/geom/neutral/ngeom_tessellate.h +++ b/src/geom/neutral/ngeom_tessellate.h @@ -32,6 +32,10 @@ struct TessParams { // relative to model_scale (imperceptible facets), so dense assemblies // of tiny curved features (bolts/pins) don't blow the triangle budget // while large visible surfaces keep the fine max_angle. + bool capture_face_ranges = false; // record per-face triangle ranges (TessMesh::face_ranges) so the + // GLB writer can emit clickable per-face regions. Opt-in (bloats + // the output) and forces serial face tessellation for stable + // face-order ranges. Off => no per-face bookkeeping. }; struct TessMesh { @@ -47,6 +51,16 @@ struct TessMesh { uint32_t vertex_count = 0; }; std::vector groups; + // Per-face triangle range within `indices` — populated only when TessParams.capture_face_ranges + // is set (clickable per-face regions). `first_index` is relative to this mesh; the GLB writer + // re-bases it against the owning solid's draw range. + struct FaceRange { + uint32_t first_index = 0; // flat index into `indices` + uint32_t index_count = 0; // number of indices (3 * triangles) + int64_t face_id = 0; // source entity id (STEP/IFC #id), 0 if unknown + uint32_t face_seq = 0; // 0-based face position within the solid + }; + std::vector face_ranges; // Primitive topology of `indices`: TRIANGLES for solids/faces, LINES for curve-only bodies // (index pairs). A single stream blob is one root, so it is homogeneous. MeshType mesh_type = MeshType::TRIANGLES; diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 53d3058..346435e 100644 --- a/src/geom/neutral/ngeom_topology.h +++ b/src/geom/neutral/ngeom_topology.h @@ -305,6 +305,10 @@ struct FaceSurfaceN { std::shared_ptr surface; bool same_sense = true; // false => face normal is the surface normal flipped std::vector bounds; // bounds[0] outer, rest holes + // Source entity id (STEP ADVANCED_FACE / IFC IfcFace #id), 0 when unknown. Only used when the + // caller requests per-face clickable regions (TessParams.capture_face_ranges) — lets the viewer + // report the exact source id of a clicked face. Costs nothing otherwise. + int64_t src_id = 0; // The `surface` is a PLACEHOLDER identity plane (a plain IfcFace / explicit face-set polygon whose // real 3D plane is only implied by its boundary points). The tessellator must fit the plane from // the 3D loop up front — otherwise the poly projects flat onto the z=0 placeholder plane. diff --git a/src/stp2glb/main.cpp b/src/stp2glb/main.cpp index a73cc68..4b30d55 100644 --- a/src/stp2glb/main.cpp +++ b/src/stp2glb/main.cpp @@ -24,6 +24,7 @@ int main(int argc, char *argv[]) { bool meshopt = true; // EXT_meshopt_compression baked inline by default bool profile = false; // enable the env-gated StepProfiler instrumentation bool quiet = false; // suppress the param echo + progress; keep errors + the final result line + bool face_regions = false; // bake per-face clickable regions into scenes[0].extras (opt-in) std::string spill_dir; // empty => private auto-removed mkdtemp spill dir app.add_option("--stp", stp_file, "STEP input filepath")->required(); @@ -34,6 +35,8 @@ int main(int argc, char *argv[]) { app.add_option("--num-threads", num_threads, "Worker threads (0 = all hardware cores)")->default_val(0); app.add_flag("--meshopt,!--no-meshopt", meshopt, "Bake EXT_meshopt_compression inline (default ON)"); app.add_flag("--profile", profile, "Print [STEPPROF] phase/memory/per-solid timing to stderr (StepProfiler)"); + app.add_flag("--face-regions", face_regions, + "Bake per-face clickable regions into scenes[0].extras (face_ranges_node); opt-in"); app.add_flag("--quiet", quiet, "Suppress the param echo + progress; keep errors and the final result line"); app.add_option("--spill-dir", spill_dir, "Directory for the per-lane GLB spill files (created if missing, left in place); " @@ -67,8 +70,8 @@ int main(int argc, char *argv[]) { const auto start = std::chrono::high_resolution_clock::now(); long nsolids = -1; try { - nsolids = - adacpp::stream_step_to_glb(stp_file, glb_file, deflection, angular_deg, num_threads, meshopt, spill_dir); + nsolids = adacpp::stream_step_to_glb(stp_file, glb_file, deflection, angular_deg, num_threads, meshopt, + spill_dir, /*model_scale=*/0.0, face_regions); } catch (const std::exception &ex) { std::cerr << "Error: " << ex.what() << "\n"; return 1; From 8c1cafcd180e1c6f4bc586170c3615309627ba5e Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 13:20:04 +0200 Subject: [PATCH 62/65] feat: face_regions arg on the stream_step_to_glb python binding Forwards the opt-in per-face clickable-regions flag through the nanobind binding so adapy can request face_ranges_node emission (gated on the binding advertising the arg in __doc__, so older extensions degrade gracefully). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cad/cad_py_wrap.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cad/cad_py_wrap.cpp b/src/cad/cad_py_wrap.cpp index 6e2c913..a9169a1 100644 --- a/src/cad/cad_py_wrap.cpp +++ b/src/cad/cad_py_wrap.cpp @@ -738,9 +738,10 @@ Mesh stream_step_to_meshes_impl(const std::string &path, const std::string &pipe // now lives in step_to_glb_stream.h (adacpp::stream_step_to_glb) so the standalone OCC-free STP2GLB // CLI can reuse it without nanobind/OCCT. This thin wrapper keeps the existing python binding. int stream_step_to_glb_impl(const std::string &in_path, const std::string &out_path, double deflection, - double angular_deg, int num_threads, bool meshopt, double model_scale) { + double angular_deg, int num_threads, bool meshopt, double model_scale, + bool face_regions) { return (int) adacpp::stream_step_to_glb(in_path, out_path, deflection, angular_deg, num_threads, meshopt, - /*spill_dir=*/"", model_scale); + /*spill_dir=*/"", model_scale, face_regions); } // Native OCC-free IFC -> GLB (IfcResolver: geometry + colour + spatial tree, baked to metres). @@ -3793,6 +3794,7 @@ void cad_module(nb::module_ &m) { m.def("stream_step_to_glb", &stream_step_to_glb_impl, "in_path"_a, "out_path"_a, "deflection"_a = 0.0, "angular_deg"_a = 20.0, "num_threads"_a = 0, "meshopt"_a = true, "model_scale"_a = 0.0, + "face_regions"_a = false, "Native STEP -> GLB file: stream the .stp with the native reader (offset index + per-statement " "pread, bounded memory), tessellate each solid across num_threads worker threads (0 = auto = " "hardware_concurrency clamped to the cgroup cpu quota), each owning a spill lane joined at the " From e00d0ef616f004434d76355f8a30516ae3e60a5d Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 14:17:39 +0200 Subject: [PATCH 63/65] fix: respect curved trim on near-rectangular B-spline faces (bevels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit full_patch_rect filled a B-spline face's UV bounding box as a full grid whenever the trim loop covered >=92% of that bbox — discarding the curved trim edges and replacing them with straight bbox edges. On the ventilator this hit 120 faces: every bevel cut to fit its neighbours (e.g. face #8515, ratio 0.947) rendered as a simple rectangular extrusion instead of following its trim, unlike OCC. A truly untrimmed patch fills its bbox exactly (straight domain edges lose no area, ratio ~1.0), so raise the threshold to 0.995 — materially-trimmed faces now route through the trim-respecting emit_uv_region path. 0 dropped faces; ventilator 56.5k -> 89.4k tris (the extra follow the real trim); all NGEOM suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_tessellate.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index e1c3305..c0fa8a1 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -698,7 +698,14 @@ Rect full_patch_rect(const Surface &s, const std::vector> &conto double ddu = std::abs(du1 - du0), ddv = std::abs(dv1 - dv0); if (ddu < 1e-12 || ddv < 1e-12 || du < 1e-6 * ddu || dv < 1e-6 * ddv) return {}; - if (std::abs(poly_area(contours[0])) < 0.92 * du * dv) + // Fill the UV bbox as a full grid ONLY when the trim loop essentially IS that rectangle. A truly + // untrimmed patch fills its bbox exactly (straight u=const/v=const domain edges lose no area when + // discretized, so ratio ~1.0); anything materially below that has curved trim edges cutting into + // the bbox (e.g. a bevel cut to fit its neighbours — ratio ~0.95), and filling the rectangle would + // replace those trim curves with straight bbox edges. Those must go through the trim-respecting + // emit_uv_region path instead. + double area_ratio = std::abs(poly_area(contours[0])) / (du * dv); + if (area_ratio < 0.995) return {}; return {clampd(umin, du0, du1), clampd(umax, du0, du1), clampd(vmin, dv0, dv1), clampd(vmax, dv0, dv1), true}; } From 3e18e9f16f701a9d48b3f530cf5fed9769528fde Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 15:16:07 +0200 Subject: [PATCH 64/65] fix: angular (normal-deviation) refinement so curved surfaces aren't faceted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refine_uv only split triangles on chord deviation, with a 4*deflection sub-resolution floor. On a small model — or any thin/shallow curved feature — the chord sag falls below tolerance, so curved INTERIORS were never subdivided while their boundary curves were sampled fine: bevels flattened (ventilator #7030 inconsistent along the edge), cylinders tessellated as prisms and holes as squares (as1-oc-214 rods/bolts). Add a scale-invariant angular split — subdivide an edge whose surface normal turns more than max_angle across it — reusing the adaptive per-surface angle so tiny features on large assemblies still coarsen. ventilator 89k->179k (smooth bevels), as1 8.8k->21.6k (round rods/bolts), crane 53M->78.8M (round members). 0 new NGEOM failures. STP2GLB gains a --model-scale flag to exercise the adaptive path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/geom/neutral/ngeom_tessellate.cpp | 25 ++++++++++++++++++++++--- src/stp2glb/main.cpp | 6 +++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index c0fa8a1..db91751 100644 --- a/src/geom/neutral/ngeom_tessellate.cpp +++ b/src/geom/neutral/ngeom_tessellate.cpp @@ -377,7 +377,7 @@ void delaunay_flip(const std::vector &verts, std::vector &tris, double } void refine_uv(const Surface &s, std::vector &verts, std::vector &tris, double max_du, double max_dv, - double defl, bool have_metric, double su, double sv) { + double defl, bool have_metric, double su, double sv, double max_angle) { std::vector pts3(verts.size()); for (size_t i = 0; i < verts.size(); ++i) pts3[i] = s.point(verts[i][0], verts[i][1]); @@ -411,9 +411,20 @@ void refine_uv(const Surface &s, std::vector &verts, std::vector &tris, auto key = [](uint32_t i, uint32_t j) { return std::make_pair(std::min(i, j), std::max(i, j)); }; + // Angular refinement: split an edge whose surface normal turns more than the (adaptive) max_angle + // across it. This is SCALE-INVARIANT, so it captures curved-but-shallow features (a bevel cut into + // a large solid, whose chord sag is far below `defl` — the chord-deviation test alone misses it + // entirely on a small model). Uses the SAME adaptive relaxation as the boundary discretization so a + // tiny curved feature (a bolt on a big assembly) doesn't over-refine — only surfaces at/above ~1% + // of the model diagonal get the fine angle. Floored by an absolute min 3D edge length so a sharp + // fillet can't recurse without bound; the 12-pass + budget caps below also bound it. + const double eff_angle = adaptive_max_angle(s.approx_size(), max_angle); + const double cos_max_angle = eff_angle > 0.0 ? std::cos(eff_angle) : -2.0; + const double ang_min_len2 = std::pow(std::max(s.approx_size() * 1e-3, 1e-6), 2.0); + for (int pass = 0; pass < 12; ++pass) { std::set> marked; - // 0 = no, 1 = parametric, 2 = deviation + // 0 = no, 1 = parametric, 2 = deviation, 3 = angular auto edge_needs_split = [&](uint32_t i, uint32_t j) -> int { const Uv &a = verts[i]; const Uv &b = verts[j]; @@ -424,6 +435,14 @@ void refine_uv(const Surface &s, std::vector &verts, std::vector &tris, Vec3 pa = pts3[i]; Vec3 chord = pts3[j] - pa; double l2 = chord.dot(chord); + // Angular split runs BEFORE the chord sub-resolution floor: a small edge across a rounded + // feature has a large normal turn but a tiny chord sag, so the floor below would wrongly skip it. + if (max_angle > 0.0 && l2 > ang_min_len2) { + Vec3 na = s.normal(a[0], a[1]); + Vec3 nb = s.normal(b[0], b[1]); + if (na.dot(nb) < cos_max_angle) + return 3; + } if (l2 < (4.0 * defl) * (4.0 * defl)) return 0; // sub-resolution floor Vec3 m = s.point((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5); @@ -578,7 +597,7 @@ void refine_and_emit(const Surface &s, std::vector verts, std::vector t bool needs_dev = dynamic_cast(&s) == nullptr; size_t pre_refine = tris.size(); if (std::isfinite(max_du) || std::isfinite(max_dv) || needs_dev) - refine_uv(s, verts, tris, max_du, max_dv, tp.deflection, needs_dev, su, sv); + refine_uv(s, verts, tris, max_du, max_dv, tp.deflection, needs_dev, su, sv, tp.max_angle); if (TESSDBG) { std::fprintf(stderr, "TESSDBG refine_and_emit surf=%s", surf_kind(s)); log_surf_params(s); diff --git a/src/stp2glb/main.cpp b/src/stp2glb/main.cpp index 4b30d55..f752841 100644 --- a/src/stp2glb/main.cpp +++ b/src/stp2glb/main.cpp @@ -25,6 +25,7 @@ int main(int argc, char *argv[]) { bool profile = false; // enable the env-gated StepProfiler instrumentation bool quiet = false; // suppress the param echo + progress; keep errors + the final result line bool face_regions = false; // bake per-face clickable regions into scenes[0].extras (opt-in) + double model_scale = 0.0; // >0 => adaptive per-surface density (relaxes tiny features); 0 => fixed std::string spill_dir; // empty => private auto-removed mkdtemp spill dir app.add_option("--stp", stp_file, "STEP input filepath")->required(); @@ -37,6 +38,9 @@ int main(int argc, char *argv[]) { app.add_flag("--profile", profile, "Print [STEPPROF] phase/memory/per-solid timing to stderr (StepProfiler)"); app.add_flag("--face-regions", face_regions, "Bake per-face clickable regions into scenes[0].extras (face_ranges_node); opt-in"); + app.add_option("--model-scale", model_scale, + "Model bbox diagonal for adaptive per-surface density (0 = fixed angle)") + ->default_val(0.0); app.add_flag("--quiet", quiet, "Suppress the param echo + progress; keep errors and the final result line"); app.add_option("--spill-dir", spill_dir, "Directory for the per-lane GLB spill files (created if missing, left in place); " @@ -71,7 +75,7 @@ int main(int argc, char *argv[]) { long nsolids = -1; try { nsolids = adacpp::stream_step_to_glb(stp_file, glb_file, deflection, angular_deg, num_threads, meshopt, - spill_dir, /*model_scale=*/0.0, face_regions); + spill_dir, model_scale, face_regions); } catch (const std::exception &ex) { std::cerr << "Error: " << ex.what() << "\n"; return 1; From 5a84825abcc815be505e04298b0e5e2d6956c1f3 Mon Sep 17 00:00:00 2001 From: krande Date: Mon, 13 Jul 2026 18:49:13 +0200 Subject: [PATCH 65/65] fix(step): convert cone semi-angle from the file's plane-angle unit CONICAL_SURFACE stores its semi-angle in the model's declared plane-angle unit, which is frequently DEGREE (a CONVERSION_BASED_UNIT), not radians. The reader read the raw value as radians, so a 45-degree cone became a 45-radian cone: garbage geometry that tessellates to zero triangles and is silently dropped. On degree-unit assemblies this dropped nearly every conical face. Collect CONVERSION_BASED_UNIT records alongside LENGTH_UNIT in both scan paths, add detect_angle_scale() (follows the plane-angle unit's PLANE_ANGLE_MEASURE_WITH_UNIT to its radians-per-unit factor, extracting the value from the typed PLANE_ANGLE_MEASURE(v) which parses as Keyword + List), and scale the cone semi-angle by it. Radian models are unchanged (factor defaults to 1.0). Adds a degree-cone reader regression test. --- src/cadit/step/step_reader.h | 53 +++++++++++++++++++++++++++++++-- tests/step/test_step_reader.cpp | 36 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index 4aa3f35..1ab2819 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -580,7 +580,11 @@ class StreamIndex { const char *n0 = p; while (p < end && (std::isalnum((unsigned char) *p) || *p == '_')) ++p; - if (std::string_view(n0, p - n0) == "LENGTH_UNIT") + std::string_view sub0(n0, p - n0); + // LENGTH_UNIT complex records feed detect_unit_scale; CONVERSION_BASED_UNIT records (e.g. a + // 'DEGREE' plane-angle unit) feed detect_angle_scale so a degree-declared cone semi-angle is + // converted to radians instead of read as radians (which drops the face). + if (sub0 == "LENGTH_UNIT" || sub0 == "CONVERSION_BASED_UNIT") lists.units.push_back(id); return; } @@ -690,6 +694,7 @@ class Resolver { build_colour_map(tl.styled); build_product_name_map(tl.sdr); unit_scale_ = detect_unit_scale(tl.units); // BEFORE build_transform_map — its rep_factor needs it + angle_scale_ = detect_angle_scale(tl.units); // radians per file plane-angle unit (DEGREE etc.) build_transform_map(tl.roots, tl.absr, tl.srr, tl.cdsr, tl.nauo, tl.sdr); clear_geom_cache(); } @@ -703,6 +708,7 @@ class Resolver { path_map_ = src.path_map_; name_of_rep_ = src.name_of_rep_; unit_scale_ = src.unit_scale_; + angle_scale_ = src.angle_scale_; } // Resolve one root solid -> NgeomRoot (geometry + colour + per-instance transforms + paths). @@ -923,7 +929,7 @@ class Resolver { TypeLists tl; for (const auto &[id, in] : *m_) { if (in->complex) { - if (sub(in, "LENGTH_UNIT")) + if (sub(in, "LENGTH_UNIT") || sub(in, "CONVERSION_BASED_UNIT")) tl.units.push_back(id); continue; } @@ -978,6 +984,7 @@ class Resolver { // parallel writers for those other paths too. bool bound_parse_cache_ = false; double unit_scale_ = 1.0; + double angle_scale_ = 1.0; // radians per the file's plane-angle unit (1.0 = radians; ~0.01745 = degrees) std::unordered_map> surf_cache_; std::unordered_map> curve_cache_; std::unordered_map> face_cache_; @@ -1362,7 +1369,9 @@ class Resolver { else if (t == "CYLINDRICAL_SURFACE" && in->args.size() >= 3) s = std::make_shared(fr, in->args[2].as_double()); else if (t == "CONICAL_SURFACE" && in->args.size() >= 4) - s = std::make_shared(fr, in->args[2].as_double(), in->args[3].as_double()); + // semi-angle is a plane-angle -> convert the file's unit (often DEGREE) to radians. + s = std::make_shared(fr, in->args[2].as_double(), + in->args[3].as_double() * angle_scale_); else if (t == "SPHERICAL_SURFACE" && in->args.size() >= 3) s = std::make_shared(fr, in->args[2].as_double()); else if (t == "TOROIDAL_SURFACE" && in->args.size() >= 4) @@ -1744,6 +1753,44 @@ class Resolver { return best; } + // Radians per the model's plane-angle unit. Default 1.0 (SI RADIAN — the common case). A + // CONVERSION_BASED_UNIT for PLANE_ANGLE (e.g. 'DEGREE') returns its conversion factor to radians + // (~0.01745). Without this, an angle value read from the file (a cone's semi-angle) is treated as + // radians: a 45-degree cone becomes a 45-radian cone (nonsense), its faces tessellate to zero + // triangles and are silently dropped. + double detect_angle_scale(const std::vector &units) { + for (long id : units) { + const Instance *in = inst(id); + if (!in || !in->complex) + continue; + const auto *cbu = sub(in, "CONVERSION_BASED_UNIT"); + if (!cbu || !sub(in, "PLANE_ANGLE_UNIT")) + continue; + // CONVERSION_BASED_UNIT(name, factor_ref); factor_ref -> + // PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(v), base) — v is radians per this unit. + // The typed value PLANE_ANGLE_MEASURE(v) parses as a Keyword arg + a List[v] arg, so scan + // for the first numeric (or single-element numeric list) among the measure's args. + for (const Value &a : *cbu) { + if (!a.is_ref()) + continue; + const Instance *mwu = inst(a.i); + if (!mwu || mwu->type != "PLANE_ANGLE_MEASURE_WITH_UNIT") + continue; + for (const Value &x : mwu->args) { + double v = 0.0; + if (x.kind == Kind::Real || x.kind == Kind::Int) + v = x.as_double(); + else if (x.kind == Kind::List && !x.items.empty() && + (x.items[0].kind == Kind::Real || x.items[0].kind == Kind::Int)) + v = x.items[0].as_double(); + if (v > 1e-12) + return v; + } + } + } + return 1.0; + } + // Length-unit scale (to metres) of a representation's OWN context (its // GLOBAL_UNIT_ASSIGNED_CONTEXT unit list), or -1 if it declares no length unit. Mixed // mm/cm/metre files assign units per representation — mirrors the Python diff --git a/tests/step/test_step_reader.cpp b/tests/step/test_step_reader.cpp index 871bade..2ff402a 100644 --- a/tests/step/test_step_reader.cpp +++ b/tests/step/test_step_reader.cpp @@ -151,6 +151,41 @@ static void test_curved_surfaces_and_polyloop() { CHECK(vclose(lp.polygon[2], 1, 1, 0), "poly-loop point 2"); } +// A single conical face whose semi-angle (45) is declared in DEGREES via a CONVERSION_BASED_UNIT +// plane-angle unit. The reader must convert it to radians (pi/4), not read 45 as radians — the +// latter produces a garbage cone that tessellates to nothing (the GeneratorSet dropped-faces bug). +static const char *kDegreeConeSolid = + "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n" + "#1=CARTESIAN_POINT('',(0.,0.,0.));\n" + "#2=CARTESIAN_POINT('',(1.,0.,0.));\n" + "#3=CARTESIAN_POINT('',(1.,1.,0.));\n" + "#4=CARTESIAN_POINT('',(0.,1.,0.));\n" + "#5=DIRECTION('',(0.,0.,1.));\n" + "#6=DIRECTION('',(1.,0.,0.));\n" + "#7=AXIS2_PLACEMENT_3D('',#1,#5,#6);\n" + "#10=POLY_LOOP('',(#1,#2,#3,#4));\n" + "#11=FACE_OUTER_BOUND('',#10,.T.);\n" + "#21=CONICAL_SURFACE('',#7,2.,45.);\n" + "#31=ADVANCED_FACE('con',(#11),#21,.T.);\n" + "#40=CLOSED_SHELL('',(#31));\n" + "#50=MANIFOLD_SOLID_BREP('cone',#40);\n" + "#22=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n" + "#24=PLANE_ANGLE_MEASURE_WITH_UNIT(PLANE_ANGLE_MEASURE(0.017453292519943295),#22);\n" + "#28=(CONVERSION_BASED_UNIT('DEGREE',#24)NAMED_UNIT(#22)PLANE_ANGLE_UNIT());\n" + "ENDSEC;\nEND-ISO-10303-21;\n"; + +static void test_degree_angle_unit() { + std::vector store; + ng::NgeomDoc doc = read_step_brep(kDegreeConeSolid, store); + CHECK(doc.roots.size() == 1 && doc.roots[0].faces.size() == 1, "degree-cone solid: 1 face"); + if (doc.roots.empty() || doc.roots[0].faces.empty()) + return; + auto *con = dynamic_cast(doc.roots[0].faces[0]->surface.get()); + CHECK(con && std::abs(con->r0 - 2.0) < 1e-9, "cone radius 2"); + // 45 DEGREE -> pi/4 rad; read as radians it would stay 45 and the cone would tessellate empty. + CHECK(con && std::abs(con->semi_angle - 0.78539816339744831) < 1e-6, "semi-angle 45 DEG -> pi/4 rad"); +} + // A circular disk: a planar face bounded by one full-circle EDGE_CURVE (CIRCLE geometry). static const char *kDiskSolid = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n" "#1=CARTESIAN_POINT('',(0.,0.,0.));\n" @@ -464,6 +499,7 @@ int main() { test_resolve_structure(); test_tessellate(); test_curved_surfaces_and_polyloop(); + test_degree_angle_unit(); test_conic_edge(); test_bspline_surfaces(); test_bspline_edge_curve();