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/CMakeLists.txt b/CMakeLists.txt index 03083fd..b648cd9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,8 @@ 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_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) @@ -149,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 / 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 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 () @@ -201,6 +204,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/cmake/wasm_build.cmake b/cmake/wasm_build.cmake index c412d71..774fd5c 100644 --- a/cmake/wasm_build.cmake +++ b/cmake/wasm_build.cmake @@ -52,6 +52,56 @@ 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 () + +# 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/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. diff --git a/pixi.toml b/pixi.toml index 4f56e1a..8cccd84 100644 --- a/pixi.toml +++ b/pixi.toml @@ -86,6 +86,8 @@ 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)" } +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/adacpp/cad/__init__.py b/src/adacpp/cad/__init__.py index 30a265e..7cdcef2 100644 --- a/src/adacpp/cad/__init__.py +++ b/src/adacpp/cad/__init__.py @@ -24,20 +24,24 @@ 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 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 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 +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 @@ -143,6 +147,7 @@ "step_parity", "StepRootMeta", "StepNgeomStream", + "IfcNgeomStream", "meshopt_simplify_mesh", "bbox", "obb", diff --git a/src/cad/brep_file_convert.h b/src/cad/brep_file_convert.h new file mode 100644 index 0000000..fa20881 --- /dev/null +++ b/src/cad/brep_file_convert.h @@ -0,0 +1,492 @@ +// 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 "mem_tune.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"); + adacpp::tune_malloc_for_streaming(); + 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 d8c381b..a9169a1 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" @@ -14,9 +15,11 @@ #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) +#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) @@ -55,6 +58,7 @@ #include "posix_compat.h" #include "mem_trim.h" +#include "mem_tune.h" #include "effective_concurrency.h" #include @@ -256,7 +260,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 +287,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); } } } @@ -385,15 +395,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, - nb::dict settings, int threads) { +Mesh tessellate_stream_impl(nb::object buffer, const std::string &pipeline, double deflection, double angular_deg, + 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 + // 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. @@ -408,6 +428,7 @@ Mesh tessellate_stream_impl(nb::bytes buffer, const std::string &pipeline, doubl 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-" @@ -425,7 +446,7 @@ Mesh tessellate_stream_impl(nb::bytes buffer, const std::string &pipeline, doubl 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; } @@ -436,12 +457,28 @@ 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)); + // 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)); +} + // 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 @@ -500,16 +537,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_++]); @@ -517,6 +572,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; @@ -527,18 +583,111 @@ 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)); + 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; }; +// 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. +// 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") { + 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); + std::string guid = r_->product_guid(pid); + r_->clear_cache(); // bounded memory: statement/surface caches don't grow across products + // 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 || root.sweep || + !root.polylines.empty(); + if (root.faces.empty() && !has_solid) { + // 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()); + 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); + 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_; + 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 @@ -589,17 +738,29 @@ 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, + bool face_regions) { + return (int) adacpp::stream_step_to_glb(in_path, out_path, deflection, angular_deg, num_threads, meshopt, + /*spill_dir=*/"", model_scale, face_regions); +} + +// 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, int num_threads) { + return (int) adacpp::stream_ifc_to_glb(in_path, out_path, deflection, angular_deg, meshopt, + /*spill_dir=*/"", model_scale, num_threads); } // 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 @@ -913,9 +1074,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. @@ -2642,188 +2805,26 @@ 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; - long brep = em.emit_advanced_brep(buf, root); - if (!brep) - return; - 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','AdvancedBrep',(#" + std::to_string(brep) + "))"); - 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; - 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) - - 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; - 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); - } else { - aid = it->second; - } - parent = aid; - parent_is_storey = false; - } - (parent_is_storey ? storey_children : children[parent]).push_back(proxies[i]); - } - for (const auto &[pid, kids] : 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"; - } - if (!storey_children.empty()) { - std::string refs = "("; - for (size_t j = 0; j < storey_children.size(); ++j) - refs += (j ? ",#" : "#") + std::to_string(storey_children[j]); - refs += ")"; - out += "#" + std::to_string(next_id()) + "=IFCRELCONTAINEDINSPATIALSTRUCTURE('" + ifc_guid(aguid++) + - "',$,$,$," + refs + ",#" + std::to_string(STOREY) + ");\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"; - // 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. - 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 += "#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"; - 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) { +// 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 +// 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; - auto idx = adacpp::step::StreamIndex::from_file(in_path); - adacpp::step::Resolver r(idx); - r.build_metadata(idx.lists); - r.enable_parse_cache_bounding(); - fs.unit_scale = r.unit_scale(); + fs.unit_scale = unit_scale; std::FILE *fp = std::fopen(out_path.c_str(), "wb"); if (!fp) return fs; @@ -2835,20 +2836,33 @@ static adacpp::ifc_emit::FileStats write_ifc_file_impl(const std::string &in_pat buf.clear(); } }; - buf += ifc_header_block(schema, r.unit_scale()); - BrepEmitter em(100, nullptr, deflection, angular_deg); + buf += ifc_header_block(schema, unit_scale); + BrepEmitter em(100, nullptr, 2.0, 20.0); 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; - 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(); + 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); @@ -2916,26 +2930,31 @@ 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)"); + adacpp::tune_malloc_for_streaming(); 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); - // 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) roots[i] = cost[i].second; } - const long K = 17; // ids #1..#17 are the shared header block (Project/Site/Building/Storey + rels) + prof.phase("lpt_order"); + 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. @@ -2968,6 +2987,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]; @@ -2985,6 +3009,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. @@ -3023,6 +3049,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); @@ -3031,6 +3060,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"); @@ -3074,79 +3104,10 @@ 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; } -// 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. -static bool 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.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 false; - 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; -} // 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 @@ -3158,10 +3119,14 @@ 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"); + adacpp::tune_malloc_for_streaming(); 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); @@ -3169,12 +3134,13 @@ 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) 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(); @@ -3200,6 +3166,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]; @@ -3217,6 +3188,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; @@ -3271,6 +3244,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); @@ -3279,6 +3255,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); @@ -3310,6 +3287,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; } @@ -3366,7 +3344,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) @@ -3464,90 +3442,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; - auto idx = adacpp::step::StreamIndex::from_file(in_path); - 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(); - 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(); - } - }; - for (long pid : roots) { - NgeomRoot root = r.resolve_product(pid); - if (root.faces.empty() && !root.extrusion && !root.revolve && !root.boolean) { - ++fs.products_skipped; // a product whose geometry the analytic reader couldn't represent - continue; - } - ++fs.solids_in; - 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); - if (emit_solid_step(em, buf, root, pid)) { - nid = em.current_id(); - any = true; - 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); - } - const char *foot = "ENDSEC;\nEND-ISO-10303-21;\n"; - buf += foot; - flush(true); - std::fclose(fp); - return fs; -} void cad_module(nb::module_ &m) { // Kernel-agnostic mesh / color / group types live in cad — they're the @@ -3604,12 +3498,47 @@ 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) .def_ro("has_color", &StepRootMeta::has_color) .def_ro("color", &StepRootMeta::color) .def_ro("transforms", &StepRootMeta::transforms) @@ -3668,7 +3597,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' | " @@ -3730,6 +3659,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( @@ -3836,7 +3793,8 @@ 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, + "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 " @@ -3845,8 +3803,16 @@ 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, "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. 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, + "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. " @@ -3883,6 +3849,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_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 += "#"; 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); +} diff --git a/src/cad/ifc_reader.h b/src/cad/ifc_reader.h index 1e34650..9bb8790 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,27 @@ 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. + 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) + 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))) + 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); + 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; @@ -113,6 +131,207 @@ 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_; + } + + // 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; + } + + // 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; + } 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); + } + } + } + } + // 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_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_or_guid(*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) { @@ -122,23 +341,95 @@ 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); 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 + // 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; + } + } + } + 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) 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; + 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; + } + } + // 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_) { @@ -147,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) { @@ -171,13 +500,114 @@ 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(); + } + + // 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. + 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_; + voids_ = m.voids_; + openings_ = m.openings_; + 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_; + 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) + 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; + 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) { if (id <= 0) @@ -227,6 +657,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) { @@ -460,14 +902,28 @@ 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; + 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; for (const Value &bref : in->args[0].items) { @@ -504,17 +960,88 @@ 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, "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")) { // (.., 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), @@ -608,7 +1135,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) { @@ -617,6 +1150,56 @@ 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). + 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) { @@ -624,24 +1207,160 @@ 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]); + } + } 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); @@ -669,7 +1388,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; } @@ -677,7 +1396,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. @@ -769,18 +1488,539 @@ 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)); // 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) // 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)); + 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)) { + 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()) + 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); + } 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); + } 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; + } + + // ---- 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); + 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; + } + // 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, "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) + 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) + 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}}; + 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]); + } + // 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()) + 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. std::shared_ptr mk_boolean(const Instance *in) { @@ -905,6 +2145,20 @@ 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)); + else + root.recognized_empty = true; // recognized curve, but degenerate (e.g. zero-length segment) + return; + } SolidItemN it = resolve_solid_item(id); if (!solid_ok(it)) return; // unsupported geometry -> product yields nothing here -> OCC fallback @@ -919,6 +2173,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]) @@ -975,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) @@ -995,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; diff --git a/src/cad/ifc_to_glb_stream.h b/src/cad/ifc_to_glb_stream.h new file mode 100644 index 0000000..864e458 --- /dev/null +++ b/src/cad/ifc_to_glb_stream.h @@ -0,0 +1,199 @@ +// 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. 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" +#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, 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; + // 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; + + 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; + 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; + + std::atomic nwritten{0}; + bool ok = false; + { // 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, tpp); + if (tm.indices.empty()) + return; + if (tm.mesh_type == MeshType::LINES) + 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); + 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.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); + // 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(); + } + // 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()); + 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; +} + +} // namespace adacpp 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_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 diff --git a/src/cad/step_to_glb_stream.h b/src/cad/step_to_glb_stream.h index 77c1f92..4de62a8 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 @@ -23,6 +25,7 @@ #include #include "mem_trim.h" +#include "mem_tune.h" #include "effective_concurrency.h" #include "posix_compat.h" @@ -40,9 +43,12 @@ 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, 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 + 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. @@ -54,6 +60,8 @@ 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 + 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); @@ -65,16 +73,39 @@ 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()); + 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; 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_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 + // 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 +132,99 @@ 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], 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; + 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); + // 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 + // 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); + // 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) + 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 +237,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 += @@ -200,6 +271,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 8def223..cdf941f 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" @@ -157,10 +158,12 @@ 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"); + 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()) @@ -170,6 +173,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); @@ -177,17 +181,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_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) 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 +228,59 @@ 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); + // 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(); + } + 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 +290,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(); @@ -330,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/cadit/step/step_reader.h b/src/cadit/step/step_reader.h index c456ae3..1ab2819 100644 --- a/src/cadit/step/step_reader.h +++ b/src/cadit/step/step_reader.h @@ -33,7 +33,12 @@ #include "../../cad/posix_compat.h" +#ifdef ADACPP_HAVE_ZLIB +#include +#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" @@ -111,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 }; @@ -165,9 +171,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 +198,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 +236,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 +266,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; @@ -539,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; } @@ -548,7 +593,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); @@ -565,13 +611,52 @@ 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); } - 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 { @@ -609,7 +694,8 @@ 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); + 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(); } @@ -622,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). @@ -646,6 +733,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; @@ -741,6 +831,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_; } @@ -758,13 +929,13 @@ 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; } 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); @@ -778,6 +949,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); } @@ -811,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_; @@ -956,6 +1130,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) @@ -984,6 +1176,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()) @@ -1175,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) @@ -1316,6 +1512,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) @@ -1493,23 +1690,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") @@ -1520,9 +1708,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) { @@ -1567,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 @@ -1705,7 +1929,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 @@ -1823,6 +2048,85 @@ 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; + // 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; + // 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)}; + } + } } }; 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/src/geom/neutral/ngeom_bspline.h b/src/geom/neutral/ngeom_bspline.h index 1f73ad2..67c9cb9 100644 --- a/src/geom/neutral/ngeom_bspline.h +++ b/src/geom/neutral/ngeom_bspline.h @@ -454,10 +454,79 @@ 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. + // 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 = 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; + } + } + // 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) 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. 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 47004f1..eeb2d20 100644 --- a/src/geom/neutral/ngeom_encode.h +++ b/src/geom/neutral/ngeom_encode.h @@ -28,11 +28,30 @@ 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.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; + 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 +111,82 @@ 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 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); + 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.sweep) + return sweep_rec(it.sweep); + 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); + 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();) { size_t j = i + 1; diff --git a/src/geom/neutral/ngeom_glb.h b/src/geom/neutral/ngeom_glb.h index 127f385..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; @@ -516,26 +564,80 @@ 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 { 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 + + // 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 +648,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 +657,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,35 +666,68 @@ 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; } - // 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() { - 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 +738,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_; }; @@ -655,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; } } @@ -677,14 +812,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 +826,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 +847,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 +868,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)); diff --git a/src/geom/neutral/ngeom_profile.h b/src/geom/neutral/ngeom_profile.h index 68aeb08..3141374 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(); @@ -144,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() { @@ -198,12 +207,106 @@ 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 += "]"; + } + 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) + ",\"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()); + } } 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; @@ -211,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; diff --git a/src/geom/neutral/ngeom_surfaces.h b/src/geom/neutral/ngeom_surfaces.h index 54e0b25..a04744f 100644 --- a/src/geom/neutral/ngeom_surfaces.h +++ b/src/geom/neutral/ngeom_surfaces.h @@ -15,16 +15,52 @@ 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) return max_angle; + 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); - double lo = 2.0 * PI / 180.0; - double hi = std::max(max_angle, lo); return clampd(a, lo, hi); } 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 diff --git a/src/geom/neutral/ngeom_tessellate.cpp b/src/geom/neutral/ngeom_tessellate.cpp index a150e45..db91751 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) @@ -37,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)) @@ -359,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]); @@ -393,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]; @@ -406,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); @@ -560,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); @@ -680,7 +717,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}; } @@ -776,9 +820,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)); @@ -1423,8 +1476,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; @@ -1442,7 +1507,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); } @@ -1462,26 +1527,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; } @@ -1521,6 +1607,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) @@ -1562,6 +1650,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. @@ -1579,11 +1689,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(); @@ -1608,6 +1726,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]; @@ -1617,8 +1736,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 @@ -1750,6 +1908,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); @@ -1765,6 +1927,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; @@ -1773,9 +1955,68 @@ 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); } - for (const auto &face : root.faces) - if (face) + // 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 && !tp.capture_face_ranges) { + const size_t n = root.faces.size(); + TessParams tpl = tp; + tpl.threads = 1; // no nested pools inside a face + // 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 (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); + } + } } } @@ -1788,12 +2029,27 @@ 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); + 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}); } @@ -1802,12 +2058,16 @@ 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]); + 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(); @@ -1815,6 +2075,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 b4544f3..6182b82 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 { @@ -22,6 +23,19 @@ 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 + // 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 { @@ -37,11 +51,32 @@ 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; }; // 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); diff --git a/src/geom/neutral/ngeom_topology.h b/src/geom/neutral/ngeom_topology.h index 6efa797..346435e 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 @@ -237,6 +305,14 @@ 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. + bool fit_plane_from_loop = false; }; struct ConnectedFaceSetN { @@ -309,6 +385,12 @@ 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) + // 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; diff --git a/src/geom/neutral/ngeom_weld.h b/src/geom/neutral/ngeom_weld.h new file mode 100644 index 0000000..f67e770 --- /dev/null +++ b/src/geom/neutral/ngeom_weld.h @@ -0,0 +1,157 @@ +// 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. 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; + for (int64_t q : k) { h ^= (uint64_t) q; h *= 1099511628211ull; } + return (size_t) h; + } + }; + // 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) + ++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); + 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). + 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) + 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[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]); + 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 (uint32_t ci = 0; ci < nc; ++ci) + remap[corners[cs + 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 diff --git a/src/stp2glb/main.cpp b/src/stp2glb/main.cpp index a73cc68..f752841 100644 --- a/src/stp2glb/main.cpp +++ b/src/stp2glb/main.cpp @@ -24,6 +24,8 @@ 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) + 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(); @@ -34,6 +36,11 @@ 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_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); " @@ -67,8 +74,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, face_regions); } catch (const std::exception &ex) { std::cerr << "Error: " << ex.what() << "\n"; return 1; 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; +} 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 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();