feat: pure-C++ IFC reader/writer, zero-copy NGEOM streams, tessellation perf#35
Open
Krande wants to merge 57 commits into
Open
feat: pure-C++ IFC reader/writer, zero-copy NGEOM streams, tessellation perf#35Krande wants to merge 57 commits into
Krande wants to merge 57 commits into
Conversation
- StepNgeomStream.__next__ returns a capsule-owned read-only numpy uint8 array over the encoded buffer instead of nb::bytes — removes the one memcpy per solid crossing the C++/Python boundary (the consumer, adapy's lazy ShapeStore, retains the arriving object as-is). - tessellate_stream accepts any buffer-protocol object (bytes, memoryview, the stream's numpy arrays) via PyObject_GetBuffer, so a stored blob reaches the kernel with zero copies end-to-end. - New IfcNgeomStream: streaming per-product IFC -> NGEOM on the dep-free IfcResolver (no ifcopenshell/OCC). Yields (buffer, StepRootMeta) with meta.guid = GlobalId (new StepRootMeta field, empty on the STEP path); per-product cache clearing bounds memory; .unit_scale/.products_total/ .products_skipped expose file units and coverage. v1 carries no colour or spatial hierarchy (consumer fills those from its own IFC metadata). - IfcResolver: product_guid() + clear_cache() to support the stream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The moved encode() vectors carry growth-doubling capacity slack that the lazy ShapeStore would retain long-term (~+60 MB across the crane's 7291 blobs). shrink_to_fit trades one bounded realloc at yield time for an exact-size resident buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Encoder::encode carries root faces only; a product resolving to an
extrusion/revolve/boolean (e.g. a mapped extruded solid) encoded as an
EMPTY connected-face-set — the consumer saw silently-empty geometry
('shell model has no faces' in the audit). Skip + count such products so
the consumer's kernel fallback handles them, until the encoder learns
tags 50-52.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- StepProfiler phase hooks in stream_step_to_ifc (serial + parallel), stream_step_to_step, stream_ifc_to_step, and the StepNgeomStream / IfcNgeomStream iterators (scan_index / metadata / emit or iterate phases, per-solid stats, per-thread utilisation on the parallel legs; the streams also report their pure-C++ resolve+encode share vs the consumer-inclusive iterate window). - Every hook is zero-cost when ADACPP_STEP_PROFILE is unset: profiler methods early-return on one bool, and per-thread/per-call clock reads are guarded behind prof.on(). - The destructor now also emits ONE [STEPPROF-JSON] line (locale-safe numbers) so a log-capturing consumer can parse phases/peak/parallelism into structured metrics without scraping the pretty output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el monster solids Two distinct monster-solid pathologies from the corpus audit profiles: 1. Valve Hall (65M tris, stream par-eff 1.19x): ONE 6-face solid took 114.7s and emitted 62.4M triangles. Its slit-bounded deg-7x7 B-spline patch has a degenerate u-domain (6.5e-8 wide); tessellate_unbounded clamped dv = min(v_step, du) across unrelated parameter scales -> nv = 7.8M grid rows. Full-domain B-splines now route through tessellate_uv_grid (knot-span grid + deflection refinement + the 300k budget): 114.7s -> 0.20s, 62.4M -> 4k deflection-correct triangles; the whole file 123s -> 5.4s stream, 65M -> 2.8M tris. The raw parameter grid stays for the closed quadrics, where u/v are both angles and the isotropy clamp is sound. 2. 469826 (one 61k-face solid busy 54s while the pool drains at 20s): tessellate_one_root's face-set arm gains an opt-in face-level pool (tp.threads > 1, >= 64 faces; per-face local meshes merged in face order, output identical to serial). Both stream pipelines split a tail-dominant prefix (faces >= 2048 AND >= total/nthreads — merely-large solids stay in the pool: routing them through the serialized phase-A cost the crane 26%) and run it first with every thread on one solid's faces. 469826 glb: 67.3s -> 45.5s, peak stream RSS 2.0GB -> 323MB, identical triangles. Munin crane regression-checked at identical params: 58.2s -> 59.3s stream (run noise), RSS 483 -> 485MB, solids 7291 = 7291, tris -250 of 26.05M (the deflection-correct unbounded fix), phase A correctly not engaged. Per-solid timing now rides along whenever ADACPP_STEP_PROFILE is on (two clock reads + 24 bytes per solid), and the machine-readable summary carries the 10 slowest solids (>= 100 ms) so an audit row names the monster solid without a local re-run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…OCC's 0.5 BRepMesh_IncrementalMesh defaulted to OCC's 0.5 rad (~28.6deg) angular deflection, so a large-radius arc facets into a handful of segments — the linear deflection alone can't keep it smooth because its sag tolerance scales with radius. A curved I-beam swept on a 7.25 m radius came out with ~7 arc segments (188 faces), visibly wrong; the bulge apex fell 2% short of the true extent. Pass angular=0.2 rad (~11.5deg) in all three interactive-tessellation BRepMesh calls (append_shape_triangles, its ShapeFix retry, and write_glb_bytes). The same beam now renders 404 faces with the apex matching ifcopenshell's ground-truth extent exactly. Cylinders/cones/spheres/pipes get the same smoothness; flat geometry is unaffected (no curvature to sample). Only the interactive object-tessellation path (IFC/SAT/FEM concept objects, the GLB writer) is touched — the native STEP→GLB stream kernel has its own angle_step density and is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tessellate_revolve emitted only the side walls of a revolution and left partial sweeps (angle < 2pi) open, and it derived face normals from the profile loop's winding without normalising it — so a CW profile (e.g. an imported I-beam) tessellated inside-out (negative volume) and shaded dark. - Partial revolutions now cap the two open ends with the libtess2-triangulated profile at th=0 and th=ang (mirrors tessellate_sweep's start/end caps), so a swept beam is a closed solid, not an open tube. Full turns (cylinder/cone) close on themselves and are unchanged. - After emitting this revolve's triangle range, if its signed volume is negative the whole range is flipped (swap two verts + negate normals) so normals face out regardless of the incoming loop orientation. The check only fires on negative volume, so already-correct cylinders/cones are untouched. Pairs with the adapy serialize fix that feeds the angle in radians and the axis in the position-local frame; together the crane/corpus beam-revolved-solid.ifc tessellates correctly through the production libtess2 GLB path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k shading) tessellate_extrusion emitted side walls + caps whose winding assumed a CCW profile loop, but the discretized loop orientation isn't guaranteed — so a CW-outline profile (e.g. the IPE600 I-section of beam-extruded-solid.ifc, and plates) tessellated inside-out: negative signed volume, normals facing in, the solid shading dark. Same failure the revolve had. After emitting this extrusion's triangle range, flip every triangle when its signed volume is negative (swap two verts + negate normals), mirroring the tessellate_revolve guard. Only fires on negative volume, so already-correct extrusions are untouched. The beam/plate now match OCC's +volume; sweeps were already correct and are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ams.model_scale) A fixed angular ceiling (max_angle) forces every curved surface to the same facet count regardless of size, so a dense assembly of tiny features (bolts/pins) blows the triangle budget while their facets are sub-pixel. New TessParams.model_scale (model bbox diagonal; 0 => OFF, backward compatible) makes the ceiling adaptive: angle_step -> adaptive_max_angle relaxes toward a coarse cap (~40deg) for surfaces whose radius is small relative to model_scale, while large visible surfaces keep the fine max_angle. Judged model-RELATIVE (not absolute), so a standalone small part stays fine and the same-radius feature inside a big assembly coarsens. model_scale is published per worker thread (tls_model_scale) from tessellate_one_root so the pure angle_step needs no signature change. Threaded through tessellate_stream / stream_step_to_glb / stream_step_to_mesh bindings (default 0.0). Crane (778 MB, 7291 solids) at base 10deg: 107M -> 53M tris (-51%), GLB 746 -> 375 MB — smaller than even the old global 20deg (73M), with large surfaces still at 10deg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ry edges Two NGEOM tessellation bugs that made an ACIS flat plate (mixed straight + trimmed-intcurve boundary edges) collapse to a single triangle via libtess2: - LoopN::discretize concatenated the loop's edges in stored order assuming each already runs head-to-tail. ACIS loops mixing Line and trimmed-intcurve edges arrive with inconsistent per-edge direction/order, so blind concatenation produced a self-intersecting boundary and tess2 emitted 1 triangle. Re-chain edges by nearest endpoint (flipping an edge whose tail meets the running chain). For an already-consistent loop this is a no-op (same result). - align_polyline_to_vertices returned the FULL basis polyline when the edge ran against the sample direction (ia > ib) instead of the trimmed interior stretch, zig-zagging a trimmed straight B-spline edge back across its domain. Return the [ib, ia] stretch reversed so it still goes a -> b. adacpp suite green (62); adapy cadit green on the adacpp backend (357).
…on<=0 In the audit's angular-only mode (deflection=0), angle_step computed a = 2*acos(1 - 0/r) = 2*acos(1) = 0, then clamped it UP to the 2-degree lo floor — so every quadric (torus/cylinder/cone/sphere) was tessellated at 2 degrees, 5x finer than max_angle (~10-11 degrees). A boiler component of 44k analytic faces blew up to 43.7M triangles (a small R=13/r=3 torus alone hit 29,367 tris), rendering as a dense spiky mass. deflection<=0 means "no chord tolerance -> use the coarsest allowed step (max_angle, curvature-relaxed)", so return that. Torus face 29,367->1,640 tris; the solid 43.7M->7.5M. adacpp suite green (62); adapy cadit green on the adacpp backend (359).
…ger chorded) curve_points2d read only the raw CoordList of an IfcIndexedPolyCurve, ignoring the Segments list — so every IfcArcIndex collapsed to the chord between its listed points (sharp corners on filleted profiles). It now walks the typed [Keyword, ((indices))] segment pairs: IfcLineIndex is a polyline through all its points, IfcArcIndex a 3-point circular arc discretized via a new arc_poly_2d (circumcircle + swept interpolation, chord fallback when collinear) — matching the adapy Python reader's IfcArcIndex->ArcLine. Verified vs the ifcopenshell/Python oracle on beam-extruded-solid.ifc (I-section, 4 fillet arcs): C++ ifc->step profile now 77 points (was ~20 chords), cross-section area 2849.137 mm^2 == the Python analytic profile's 0.00284914 m^2 exactly. adacpp py+step suites green (62). First piece of the IfcResolver parity port (matching the Python IFC reader). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cedural IFC The NGEOM Encoder emitted only CONNECTED_FACE_SET (a root's faces), so a faces-less procedural root (extrusion/revolve/boolean/sphere) encoded to an empty buffer — and IfcNgeomStream::next skipped every such root (the faces-only gate), dropping ALL analytic-solid IFC products to the kernel fallback. That was the core reason the pure-C++ IFC path yielded almost nothing. Teach encode() to emit tags 50-53 (extrusion/revolve/sphere + recursive boolean, layouts matching ngeom_decode.h and the adapy Python codec), and narrow the IfcNgeomStream gate to skip only roots with NO encodable geometry (empty faces AND not one of those solids — e.g. an alignment sweep). Boolean operands recurse (nested solids or shell operands). Verified: IfcNgeomStream on beam-extruded-solid.ifc now yields the extrusion (was 0 roots) and tessellates to bbox [-0.05,0,-0.1]..[0.05,1,0.1] == the ifcopenshell oracle exactly. adacpp ngeom+py+step suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ileDef Two IFC-reader parity gaps vs the adapy Python reader: - proxy_roots used a hardcoded product-type name allowlist that missed subtypes (IfcSanitaryTerminal, MEP/furniture terminals, ...), so their products yielded 0 roots. Detect a product by its Representation (arg 6 of every IfcProduct) referencing an IfcProductDefinitionShape — schema-free, catches every geometric product the ifcopenshell reader would. The name allowlist stays as a no-parse fast path for the common structural types. - Added IfcRoundedRectangleProfileDef (rectangle + 4 quarter-circle corner arcs), matching the Python reader. Verified via IfcNgeomStream vs the ifcopenshell oracle: bath-csg-solid.ifc (a rounded-rect IfcCsgSolid on an IfcSanitaryTerminal) now streams 1 root, bbox [0,0,0]..[2,0.8,0.8] exact; beam-extruded-solid still exact. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…der 50-54 Add sweep_rec (SweepN -> FIXED_REF_SWEPT_SOLID: profile face + placement + N per-station origin/dir_x/dir_y frames, layout matching ngeom_decode.h + the Python codec) and wire it into solid_root / solid_item_rec, plus root.sweep into the IfcNgeomStream has_solid gate. The NGEOM encoder now emits every analytic solid tag (50-54), matching the adapy serializer — the prerequisite for streaming the swept-solid family once the IFC reader builds SweepN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…00%-coverage push) Toward pure-C++ 100% geometry coverage (no ifcopenshell hybrid). resolve_solid_item now handles the tessellated / face-based families the Python reader covers: - IfcPolygonalFaceSet / IfcTriangulatedFaceSet: each face/triangle -> a poly-loop FaceSurfaceN from the IfcCartesianPointList3D (new point_list_3d + push_pt helpers). - IfcShellBasedSurfaceModel / IfcFaceBasedSurfaceModel / bare shells: add_shell_faces reads each shell's IfcFace list. - face() now also builds a plain IfcFace (polygonal, no analytic surface -> placeholder plane; the tessellator fits the plane from the loop) and IfcFaceSurface, and a bare face/shell is dispatched as a representation item. Corpus coverage 17/26 files fully streamed (was ~11): polygonal/triangulated tessellation, surface-model, bsplinesurfacewithknots now stream + tessellate == oracle. adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the swept-disk solid to the pure-C++ IFC path (100%-coverage push): a directrix sampler (directrix_points: IfcPolyline / IfcIndexedPolyCurve 3D + arc segments via new arc_poly_3d / IfcCompositeCurve), rotation-minimising parallel-transport frames (mirrors adapy _sweep_frames; reseeds a frame that drifts parallel to the tangent), and a circle/annulus disk profile -> SweepN, wired through solid_ok/claim_solid/resolve_item. Also fixes the encoder root-loop gate: it checked extrusion|revolve|boolean|sphere but not sweep, so a sweep-only root fell through to an EMPTY connected-face-set (streamed but 0 tris). Adding root.sweep there is what made swept solids actually render. Verified vs the ifcopenshell oracle: reinforcing-stirrup 13948 tris (was 0), bbox exact; reinforcing-assembly 34 rebar 474k tris. Corpus 18/26 fully covered. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Horizontal Complete the swept-solid readers with a fixed-reference frame builder (mk_fixed_ref_swept: the profile's local-x tracks a fixed reference direction projected perpendicular to the tangent). IfcFixedReferenceSweptAreaSolid uses its FixedReference; IfcSectionedSolidHorizontal (uniform cross-sections) uses a vertical reference; varying sections stay skipped (not a single SweepN). Correct for simple directrixes; the corpus's fixed-ref/sectioned all ride IfcGradientCurve alignment directrixes, so they light up once alignment-curve sampling lands. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… IfcDerivedProfileDef Ports the adapy alignment evaluator (ngeom._alignment_sweep) to C++ so IfcFixedReferenceSweptAreaSolid / IfcSectionedSolidHorizontal over alignment directrixes tessellate natively: - alignment_fresnel (power-series Fresnel S/C for IfcClothoid), alignment_parent_eval (IfcLine / IfcCircle / IfcClothoid / IfcCosineSpiral -> point+tangent), alignment_seg_eval (place a parent by the IfcCurveSegment's IfcAxis2Placement2D, advancing arc length by the sign of SegmentLength), alignment_sample over a composite's IfcCurveSegments, and alignment_gradient_points (IfcGradientCurve: horizontal base + z(s) from the vertical gradient). Wired into directrix_points for IfcGradientCurve / IfcSegmentedReferenceCurve / alignment IfcCompositeCurve. - IfcDerivedProfileDef in profile_face (parent outline + 2D operator transform) — the swept area of these alignment solids. The alignment SWEPT SOLIDS now stream + tessellate (fixed-ref / sectioned each render a solid). The alignment CURVE-only products (IfcAlignmentSegment/Referent axis curves) still skip — they need a GL_LINES curve representation, a separate mechanism. adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gmented curves) Adds a curve/polyline representation to the NGEOM stream so curve-only IFC products render as lines instead of skipping: - NgeomRoot.polylines + a new CURVE_SET tag (67): encode/decode N polylines of points (C++-only, not in the Python codec — these blobs are C++-produced and C++-consumed). - TessMesh.mesh_type; tessellate_one_root emits index-pair LINES for a polyline root, and tessellate_stream threads mesh_type onto the returned Mesh (MeshType::LINES). - IfcResolver: a curve-body branch in resolve_item builds root.polylines via the directrix sampler for IfcGradientCurve / IfcSegmentedReferenceCurve / IfcCompositeCurve / IfcPolyline / IfcIndexedPolyCurve / IfcTrimmedCurve / IfcCurveSegment (single alignment segment); the IfcNgeomStream gate no longer skips a polyline-only root. Corpus 18/26 -> 21/26 fully covered: segmented-reference-curve + curve-parameters(-degrees/ -radians) now 100%; fixed-ref/sectioned 8/10 (7 alignment axes as LINES + 1 swept solid). adacpp suites green (62). NOTE: the raw adacpp Mesh carries mesh_type=LINES; the adapy backend BatchMesh wrapper still needs to propagate it (MeshStore.type) for the viewer to render these as lines not triangles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…es (real solids over axis lines)
Two coupled IfcResolver fixes so products render their actual Body solid, not a reference line:
1. Representation-class filter in resolve_product: a product may carry several IfcShapeRepresen-
tations ("Body"/"Facetation"/"Tessellation" = the 3D shape; "Axis" = a reference curve;
"FootPrint"/"Annotation"/"Profile"/"Plan"/"Box" = 2D). Previously ALL items were resolved into
one root, so a beam's Axis polyline collided with its Body extrusion and an alignment merged its
3D gradient Axis with its 2D FootPrint. Now: skip 2D reps always; skip Axis when a body exists.
This exposed that several products were only ever drawing their Axis line (false coverage) while
the real solid silently failed — see (2).
2. Curved profile outlines: curve_points2d now samples IfcCompositeCurve (of IfcCompositeCurve-
Segment, honoring SameSense) and IfcTrimmedCurve over IfcLine (straight span) / IfcCircle (arc).
PARAMETER circle trims are angles in the model plane-angle unit, so plane_angle_scale() reads the
IfcConversionBasedUnit ConversionFactor -> curve-parameters-in-degrees and -in-radians now sample
the *same* semicircle (verified byte-for-byte identical tessellation, bbox 1707x1707x2000).
Result: reinforcing-assembly IfcBeam renders its 200x400x5000 Body extrusion (was the axis line);
curve-parameters columns render their semicircle solids. adacpp suites green (62).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…recognized_empty) products_skipped conflated "unsupported geometry (needs OCC fallback)" with "recognized but intentionally empty". NgeomRoot gains recognized_empty; the stream skips it WITHOUT incrementing products_skipped (so it drives no wasted OCC fallback). Set in two cases the oracle also yields nothing for: - zero-length DISCONTINUOUS IfcCurveSegment (alignment end-markers; ifcopenshell create_shape raises "Failed to process shape" on them too) -> the 2 skips in fixed-ref / sectioned. - a product with no resolvable Representation (IfcElementAssembly container reached via the is_product_type fast path) -> the 1 skip in reinforcing-assembly. Corpus 21/26 -> 24/26 fully covered; the remaining 2 are a gzip container + a degenerate texture-only fixture, not analytic-geometry gaps. adacpp suites green (62). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e_stats), Python-exposed
Ports the viewer's client-side tessellation-spike detector (adapy meshStats.ts computeRangeStats)
to dependency-free C++ (src/geom/mesh_spike.h) so audits/CI/local Python can scan converted geometry
for crows-nest spikes without the browser — one algorithm, browser and server agree.
- mesh_spike_stats(positions, indices, aspect_min=8, outlier_k=4) -> {max_spike, spike_tris,
triangles}: robust median centroid, median vertex-distance body scale, outlier vertices past
outlier_k×, thin (needle) triangles touching them.
- Exposed both as Mesh.spike_stats(...) (on a tessellate_stream result) and as a free function over
raw numpy (positions, indices) for meshes from any source. Re-exported in adacpp.cad.
- Kept OCC/NGEOM-free so it can also compile into the frontend wasm module later (unifying the
browser scan with this one).
Tests: synthetic spike (max_spike 56.6, 1 spike-tri) vs clean square (1.0, 0); free-function ==
Mesh.spike_stats on a real tessellated geom. Suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olourRgb) The pure-C++ IFC reader now emits product colour, riding the existing StepRootMeta.has_color/color rails that IfcNgeomStream already copies from the NgeomRoot (previously always uncoloured -> the viewer fell back to grey). Mirrors the STEP reader's colour_map_/find_colour: - build_colour_map(): one-time scan of IFCSTYLEDITEM, keyed on Item=arg0 (the rep-item id resolve_item consumes), value = rgba from resolve_styles_color. - resolve_styles_color(): BFS over the Styles ref-tree to the first IfcColourRgb (r/g/b=args[1..3]) + alpha from IfcSurfaceStyleShading/Rendering transparency (arg1); collects every ref arg so it handles the IFC2x3 PresentationStyleAssignment wrapper and IFC4/4x3 uniformly. - find_item_colour(): looks up a rep item, unwrapping IfcMappedItem (mapped rep items) and IfcBooleanResult/ClippingResult operands (colour rides the base operand). - resolve_product sets root.has_color/cr/cg/cb/ca from the resolved item ids, then solid_src_. Verified vs oracle: box_rotated 2/2 products = (0.8,0.8,0.8) matching its IfcColourRgb. Known gap: half_space_beam styles a SEPARATE tessellated representation disconnected from the product's analytic boolean body (its boolean carries no style) -> stays default. Geometry coverage unchanged (24/26); adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he scene tree Completes the resolver metadata trio (geometry + colour + hierarchy) so a future native IFC->GLB can build the same spatial tree from_ifc gives today. Mirrors the STEP reader's path emission onto the existing StepRootMeta.instance_paths rails (already copied by IfcNgeomStream::next): - build_rel_maps(): one-time reverse maps from IfcRelContainedInSpatialStructure (RelatedElements arg4 -> RelatingStructure arg5) and IfcRelAggregates (RelatedObjects arg5 -> RelatingObject arg4). - product_path(): walk a product up (contained-in, then aggregates) to IfcProject, emit root-first (id, name) levels as one instance_paths entry; only when a real ancestor chain exists. Verified: box_rotated -> AdaProject > base_library_concept > Topology > DOORS > door1; reinforcing -> Project > Building > ... > bar. Persistent maps survive clear_cache. adacpp suites green (65), geometry coverage unchanged (24/26). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fcColourRgb) The native STEP->IFC writer dropped colour (v1 note "carries no colour") even though the NgeomRoot already carries it — the STEP reader resolves STYLED_ITEM->COLOUR_RGB into has_color/cr/cg/cb/ca, and the IFC reader now does too. emit_solid_ifc now emits IfcColourRgb + IfcSurfaceStyleShading + IfcSurfaceStyle + IfcStyledItem keyed on the shared IfcAdvancedBrep (so it colours every mapped instance). Closes the #1 writer gap vs adapy's to_ifc / the Python streaming writer — the viewer no longer falls back to grey. Verified: flat_plate_wColors STEP->IFC emits 2 styled items = (0.098,0.098,0.098) matching the source COLOUR_RGB exactly, keyed on IfcAdvancedBrep, ifcopenshell.validate 0 errors (IFC4X3). adacpp suites green (65); adapy native-IFC-emit suite green (43). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e indexing) A .ifc/.stp may be gzip-compressed on disk (beam-standard-case-gz.ifc). gzip isn't seekable, so StreamIndex now detects the 0x1f 0x8b magic and inflates the whole file into an owned buffer, then indexes it in memory (fd/mmap/pread fast paths bypassed for gz). Handled in all three entry points (in-memory ctor, from_file mmap, from_file_pread) + move-assignment re-points buf_ at the moved owned_ buffer. zlib is linked ONLY to the Python ext (ADA_CPP_LINK_LIBS + ZLIB::ZLIB) with a target-scoped ADACPP_HAVE_ZLIB define (build_python.cmake, guarded on ZLIB_FOUND) — the minimal STP2GLB CLI, the C++ test targets, and the wasm build compile gunzip() as a no-op stub (returns empty -> 0 products, never a crash), so none need a zlib link. zlib is already in the pixi test/prod envs. Corpus 24/26 -> 25/26 (beam-standard-case-gz now 18/18 products, 0 skipped); the only remaining file is a degenerate texture-only fixture (0 products). adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/OCC The reader now emits geometry + colour + spatial hierarchy + guid, and (confirmed) the viewer's GLB only needs geometry + colour + hierarchy + names — IFC property sets never live in the GLB (fetched on selection from the source). So a fully native IFC->GLB is viewer-equivalent. src/cad/ifc_to_glb_stream.h: stream_ifc_to_glb mirrors stream_step_to_glb but drives IfcResolver (proxy_roots / resolve_product / unit_scale) instead of the STEP Resolver, feeding the SAME shared C++ GLB spill writer (ngeom_glb.h write_glb_merged) — StreamIndex -> IfcResolver -> libtess2 -> GLB, baked to metres, merge-by-colour. Single-threaded v1 (IfcResolver's colour/rel maps are per-instance; parallelising needs a metadata share). Curve-only bodies (alignment axes -> LINES) skipped (GlbSolid is triangles-only). Bound as adacpp.cad.stream_ifc_to_glb. Verified: box_rotated GLB structurally identical to the STEP native path (same node/mesh/material layout), material baseColor 0.8 == source, baked to metres, valid glTF. reinforcing-assembly = 35 products, 11.8MB meshopt GLB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atches from_ifc GLB) The instance_paths tree + picking leaves were named by the IFC Name attribute, which is empty for rebar and assemblies (they carry only a GlobalId) -> the viewer got a tree of blank nodes and empty selection info. New name_or_guid() falls back to the GlobalId (arg0) when Name (arg2) is empty, for both the product leaf (root.id) and every spatial-path level. Mirrors the Python from_ifc GLB, which keys the id_hierarchy on the guid. Verified vs the Python path on reinforcing-assembly: id_hierarchy now 37/38 GUIDs identical, 0 empty-name nodes (was all-empty). adacpp suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…der) — no tessellation The native IFC writer only did IfcAdvancedBrep, so analytic NgeomRoots (extrusion/revolve/swept-disk/ boolean/sphere — what the IFC reader produces for most real IFC) emitted nothing. ifc_emit gains the inverse of the reader so a native round-trip keeps the CSG analytic: - profile_def: FaceSurfaceN outline -> IfcArbitraryClosedProfileDef(+WithVoids). - ExtrusionN -> IfcExtrudedAreaSolid, RevolveN -> IfcRevolvedAreaSolid, SphereN -> IfcCsgSolid(IfcSphere), SweepN(disk) -> IfcSweptDiskSolid (radius recovered from the circular profile, directrix = origin[]), BooleanN -> IfcBooleanResult (recursive operands; op 0/1/2 -> DIFFERENCE/UNION/INTERSECTION). - emit_solid() dispatches face-set -> AdvancedBrep else the analytic form + the matching RepresentationType; emit_solid_ifc + blobs_to_ifc + stream_step_to_ifc all route through it. Corpus native IFC round-trip (IfcNgeomStream -> blobs_to_ifc): 24/25 files solids_out>0 + ifcopenshell 0 validate errors (the 1 skip is pure alignment curves, no solid). reinforcing = 34 IfcSweptDiskSolid + 1 extrusion; half_space_beam = 3 extrusions + 2 IfcBooleanResult. adacpp suites green (65). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SOLID / CSG sphere) step_emit gains emit_swept_disk (SweepN -> SWEPT_DISK_SOLID, radius from the circular profile, directrix = origin[] baked to world) and emit_sphere (SphereN -> CSG_SOLID over SPHERE); emit_solid_item + the root dispatch (emit_solid_step) route sweep/sphere, and write_ifc_to_step_impl's skip guard now lets root.sweep/root.sphere through (was silently dropping them). Closes the analytic gap that mirrored ifc_emit's: reinforcing IFC->STEP now emits 34 SWEPT_DISK_SOLID + 1 EXTRUDED_AREA_SOLID (was 34 skipped); half_space_beam 3 extrusion + 2 BOOLEAN_RESULT. IFC->STEP corpus 24/24 solids_out>0. adacpp green (65). NOTE: the emitted STEP is valid AP242 (parses in OCC, status RetDone) but OCC does NOT *transfer* the analytic swept/extruded solids (0 roots) — only ADVANCED_BREP_SHAPE_REPRESENTATION transfers in OCC. This pre-dates the change (the existing extrusion emit has the same limitation); the analytic forms are correct per spec and read by other STEP tools / a native reader extension. Separate reader-side concern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng/Storey) Per request — a domain-neutral spatial hierarchy so any STEP/IFC assembly round-trips without building semantics. ifc_header_block now emits IfcProject -> a root IfcSpatialZone (#12) via IfcRelAggregates (#13); emit_spatial_tree maps each assembly-path level to a nested IfcSpatialZone (RelAggregates) and contains the leaf elements in their deepest zone via IfcRelContainedInSpatialStructure (IfcSpatialZone is a valid IfcSpatialElement RelatingStructure). Dropped IfcSite/IfcBuilding/IfcBuildingStorey; header block shrank #1..#17 -> #1..#13 (K updated in the parallel writer + renumber threshold; STOREY -> ROOT zone #12). Applies to stream_step_to_ifc / write_ifc_file_impl / blobs_to_ifc alike. Verified ifcopenshell.validate 0 errors: STEP->IFC (1 zone), native IFC->IFC reinforcing (5 nested zones, the assembly tree; 0 Building/Storey). adacpp suites green (65); native-ifc-emit suite green (43). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(hierarchy round-trip) The STEP writer baked flat products (no assembly structure), so IFC->STEP dropped the hierarchy. Now emit_solid_step returns its leaf PRODUCT_DEFINITION id, and a new emit_step_assembly_tree hangs a NAUO tree off the per-leaf (product_definition, instance_path): each intermediate path level becomes an assembly PRODUCT_DEFINITION linked to its children by NEXT_ASSEMBLY_USAGE_OCCURRENCE — the STEP counterpart of the IFC emit_spatial_tree, so the tree survives IFC->STEP. Grouping nodes carry no own shape; placements stay baked into the leaf geometry. Verified: reinforcing IFC->STEP now emits 138 NAUO + 34 SWEPT_DISK_SOLID + 1 EXTRUDED_AREA_SOLID (was flat). STEP->IFC already round-trips a STEP assembly (as1-oc-214: 13 NAUO -> 5 IfcSpatialZones). adacpp green (65); native-ifc-emit green (43). REMAINING for the full native IFC->STEP->IFC round-trip: the native STEP READER (step_reader.h) does not yet parse SWEPT_DISK_SOLID (nor revolved/CSG) back into ng::, so only brep/extrusion products survive a read-back today — a reader-side follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TEP->IFC geometry round-trip) The STEP reader resolved brep/extrusion/revolve/boolean but not swept solids, so an IFC->STEP-emitted rebar (SWEPT_DISK_SOLID) was dropped on read-back. New shared header ngeom_sweep.h factors the swept-disk builder (parallel-transport frames + circular profile) out of the IFC reader so BOTH readers resolve identical geometry; step_reader gains build_swept_disk (directrix POLYLINE + radius/inner) and routes SWEPT_DISK_SOLID in resolve_root, the root-enumeration gates, and the boolean-operand dispatch. Verified: reinforcing IFC->STEP->read-back now renders 35/35 solids (155K tris, was 1); full IFC->STEP->IFC recovers 35 proxies + 34 IfcSweptDiskSolid (analytic preserved), ifcopenshell 0 validate errors. adacpp suites green (65). REMAINING (hierarchy): the IFC->STEP writer emits a NAUO product tree, but the STEP reader builds instance_paths from the CDSR/SRR *placement* graph — so the NAUO tree isn't read back (zones collapse to 1 on IFC->STEP->IFC). Aligning them (reader reads NAUO, or writer emits CDSR occurrences) is the last hierarchy step; geometry + STEP->IFC hierarchy already round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A STEP file written with baked per-leaf geometry (e.g. our own IFC->STEP emit) carries no CDSR/SRR placement graph, so world_matrices() gave every solid a flat single-level path and the spatial hierarchy collapsed on re-import. Recover it from the PRODUCT-level NEXT_ASSEMBLY_USAGE_OCCURRENCE chain: rep -> leaf PD (via SDR), child PD -> parent PD (via NAUO), walk to the assembly root and override the solid's path with the full ancestor chain (leaf level kept as the solid's own (geom_rep, product_name) so the IFC writer zones identically to the CDSR-derived paths). Closes the IFC->STEP->IFC hierarchy round-trip: as1-oc-214 STEP->IFC (7 IfcSpatialZone) -> STEP (47 NAUO, no CDSR) -> IFC now recovers the assembly tree (6 aggregates) instead of collapsing to one zone; ifcopenshell.validate reports 0 errors at every hop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…adius The native I-shape profile emitted a sharp 12-point outline, dropping the FilletRadius (arg 7) so tee-beams lost their rounded web/flange corners (e.g. IPE200 r=12 in beam-varying-cardinal-points.ifc). Add append_fillet_arc and round the four reentrant corners, clamped to min(bx-wx, fy); sharp outline retained when FilletRadius is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IFC counterpart of adacpp_step_glb: wraps stream_ifc_to_glb (native IfcResolver → libtess2 → meshoptimizer → GLB, no OCC/ifcopenshell/pyodide/ nanobind) as a standalone embind wasm module. Same neutral-geometry + GLB stack, IfcResolver front-end. - src/cad/ifc_glb_wasm.cpp: embind entry (ifcToGlb + mountOpfs), mirroring cad_wasm.cpp; WASMFS/OPFS IO so large IFC streams through pread. - cmake: BUILD_IFC_GLB_WASM option + standalone target (manifold + libtess2 + meshopt + ngeom), short-circuits the OCCT cross-build like step_glb. - pixi: wbuild-ifcglb task. Node smoke-tested (WASMFS in-heap): box_rotated, beam-extruded-solid, bath-csg-solid (CSG via manifold), half_space_beam (half-space clip), with_arc_boundary all produce valid glTF with the expected product counts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wasm counterparts of the native STEP→IFC / IFC→STEP writers, so those conversions can run fully in-browser (no OCC/ifcopenshell/pyodide/Python). To keep ONE implementation, the file→file writers + their shared emit helpers were extracted verbatim out of the nanobind cad_py_wrap.cpp into a new dep-free header src/cad/brep_file_convert.h (namespace adacpp::brep_convert): IfcPath/IfcPath2, emit_solid_ifc, emit_spatial_tree, ifc_header_block, write_ifc_file_impl, step_header_block, emit_solid_step, emit_step_assembly_tree, write_ifc_to_step_impl. cad_py_wrap.cpp now includes it + `using namespace` so its remaining nb:: writers (blobs_to_ifc, *_parallel, write_step_file, step_parity) call the shared helpers unchanged. - src/cad/brep_writer_wasm.cpp: embind (stepToIfc + ifcToStep + mountOpfs); no tessellation, so no libtess2/meshopt/manifold. - cmake: BUILD_BREP_WRITER_WASM standalone target; pixi wbuild-brepwriter. Validated: native module recompiles + relinks clean and its stream_step_to_ifc / stream_ifc_to_step produce correct output (unchanged); the wasm module node-smoke-tests STEP→IFC + IFC→STEP across flat/curved/CSG/Ventilator, with IFC→STEP byte-identical to native. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The publish-wasm-base workflow now also builds the IFC->GLB and STEP<->IFC B-rep writer embind modules (wbuild-ifcglb / wbuild-brepwriter) and stages their .js/.wasm into out/wasm/. COPY out/ /out/ carries them into the base image alongside step_glb + glb_diff, so downstream (adapy viewer) picks them up via its existing /out/wasm/* glob. Takes effect on the next adacpp release tag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… only b90d338 recovered the assembly hierarchy from the NAUO product tree for files with no CDSR placement graph (our baked IFC->STEP emit), but it fired for ANY file carrying NAUO entities and unconditionally overwrote the CDSR-derived multi-level instance_paths. On a real assembly STEP with a full CDSR graph (the crane: 10795 NAUO + 10795 CDSR) that replaced good shared paths with deep per-solid NAUO chains, exploding build_scene_extras during GLB write: write_glb_merged 5s -> 141s and peak RSS +718 MB -- the crane STEP->GLB 90s -> 253s regression. Gate the override to solids whose CDSR/world_matrices path is still FLAT (size <= 1) -- exactly the baked-file case it targets. Files with a real CDSR hierarchy keep their fast paths; the as1-oc-214 IFC->STEP->IFC roundtrip (no CDSR) still recovers its assembly tree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/trim threshold The streaming tessellator churns large transient buffers per solid (the resolved B-rep + libtess2's per-face DCEL). By default glibc keeps those freed blocks in the arena free-lists instead of returning them to the OS, so peak RSS tracks retained-but-dead memory rather than the live set. Set M_MMAP_THRESHOLD / M_TRIM_THRESHOLD to 4 MB so large freed blocks round-trip through mmap and unmap on free; the many small libtess2 allocations stay in-arena (fast). Measured (crane STEP->GLB, 39.7M tris, 3 workers): 3009 MB / 157s (default) -> 2866 MB / 159s (-143 MB, +1% time). Larger reclaim is expected on tessellation- working-set-heavy models (few tris, complex faces) whose peak is transient DCEL churn rather than one solid's live mesh. A more aggressive 128 KB threshold or M_ARENA_MAX=2 reclaim ~560-630 MB but triple the heavy-solid tessellation time (mmap/lock thrashing) -- rejected. M_ARENA_MAX alone is a no-op. Set ADACPP_NO_MALLOC_TUNE to disable. Applied once at every streaming entry point (STEP->GLB/mesh, STEP->IFC/STEP, IFC->STEP). glibc-only; no-op on macOS / Windows / wasm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation The LPT scheduler ordered solids by raw face count, which predicts tessellation time only weakly (Spearman 0.487) — the real cost is B-spline uv-inversion, so a small-face solid with dense B-spline surfaces is dispatched late and pins a worker. Add solid_cost_estimate(): face count weighted by per-face surface complexity (a B-spline surface costs ~ its control-point grid nu*nv), sampled over up to 8 faces per solid and extrapolated. ~free (a handful of index derefs, no geometry built). Route all STEP LPT sites through it (GLB / mesh / IFC / STEP / parity writers). Crane STEP->GLB (39.7M tris): Spearman(estimate,ms) 0.487 -> 0.542, and peak RSS 2866 -> 1976 MB — the weighted estimate lifts the heavy B-spline solids (e.g. #300: est 11803 -> 963419) past the phase-A fair_share threshold, so they are resolved serially with the face-level pool instead of concurrently in phase B. That is the memory-aware routing the OOM cases need, from the existing huge-solid guard, at no wall-clock cost (159 -> 151 s). Instrumentation for continuous LPT tuning (profile-gated, zero cost in prod): solid_timed now records (id, est, faces, tris, ms) per solid; the cpp_profile JSON gains spearman_est_ms (does the estimate predict duration?) and spearman_est_tris (does it predict per-solid memory?), plus est/tris on each slowest_solids row — so every profiled audit run measures the cost model's accuracy without a local re-run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stream_ifc_to_glb was single-threaded because IfcResolver built its colour / spatial-hierarchy maps lazily per instance. Add build_metadata() (build those maps once on a master) + copy_metadata_from() (share the read-only maps into per-thread worker resolvers; the statement/surface caches + pread scratch stay per-thread, and the const StreamIndex is pread-safe) so the products can be tessellated across threads — the same phase-A huge-prefix + dynamic phase-B pool as stream_step_to_glb. LPT ordering uses a new product_cost(pid): the body representation's brep face count reached by a few index derefs (product -> IfcProductDefinitionShape -> IfcShapeRepresentation -> Items -> brep -> shell), procedural items a small constant. num_threads (0 = cgroup-aware effective_concurrency) plumbed through the binding; the adapy caller passes the same worker count as the STEP path. Parallel (3 threads) matches serial product counts on all 12 local IFC fixtures (brep, CSG, half-space, swept, surface-model, 35-product assembly); single-product GLBs are byte-identical, multi-product differ only in merge-lane byte layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native GLB carries no normals, so the viewer shades from the welded geometry (vertices shared within the weld's crease angle). Analytic surfaces tessellate finely (small facet angles -> shared -> smooth), but an explicit faceted representation of a curved surface (IfcFacetedBrep / IfcPolygonalFaceSet / IfcTriangulatedFaceSet, e.g. the basin samples) has coarse facets that exceed the default 40 deg crease -> split into hard edges -> it renders faceted next to the smooth IfcAdvancedBrep of the same shape. Flag those roots in the IFC reader (SolidItemN.smooth_faceset -> NgeomRoot) and weld them with a relaxed 80 deg crease so gentle facets shade smoothly while genuine ~90 deg edges (box corners, a basin rim) stay sharp. AdvancedBrep and analytic geometry keep the 40 deg default. Wiring verified: at a 179 deg crease the box-tessellation fixtures collapse from 20 -> 8 verts (all corners weld), confirming the flag reaches weld_mesh; at 80 deg boxes stay at 20 (corners sharp). 12 local IFC fixtures convert unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… up front)
An explicit face-set / plain IfcFace (IfcFacetedBrep, IfcPolygonal- and
IfcTriangulatedFaceSet, IfcShell/FaceBasedSurfaceModel) is built with a
PLACEHOLDER identity plane (PlaneSurface{Frame{}}) and a 3D boundary polygon;
the real plane was only meant to be re-fit by the tessellator. But that re-fit
ran ONLY as an on-failure fallback (tessellate_face_impl) — and a 3D polygon
projects perfectly validly onto the z=0 placeholder plane, so face_to_mesh
SUCCEEDS and the fallback never fires. Result: the whole face-set collapses to
z=0 (the basin-faceted-brep / basin-tessellation samples rendered as a flat
sheet instead of a bowl).
Flag placeholder-plane faces (FaceSurfaceN.fit_plane_from_loop, set by the IFC
reader for plain IfcFace + the polygonal/triangulated face-set polys) and fit
the plane from the 3D loop UP FRONT, before tessellating. 2D profile faces
(extrusion SweptArea, swept-disk) are unflagged and keep the z=0 placeholder,
placed later by their solid's frame — unchanged.
basin-faceted-brep / basin-tessellation now recover the correct Z extent
(0.094 m, matching the AdvancedBrep) instead of [0,0]. 12 local IFC fixtures
(incl. surface-model, box_rotated) convert unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5a7100a relaxed the weld crease to 80° for explicit face-sets on the theory that they rendered faceted due to crease-split normals. That diagnosis was wrong: the face-sets were geometrically FLATTENED to z=0 (fixed in 46fe318 by fitting the real plane). With the geometry corrected the crease change is vestigial, so remove it and its NgeomRoot/SolidItemN.smooth_faceset plumbing — restoring the default 40° weld everywhere. The 46fe318 plane fit is untouched (basin face-sets keep their recovered Z extent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
weld_mesh grouped soup corners with unordered_map<key, vector<uint32>>, one map node + one heap-allocated vector PER soup vertex. A 61k-face solid's 16.9M-vertex soup churned ~2GB of tiny nodes/vectors. Replace with a dense first-appearance group id + a counting-sort CSR (gstart offsets / flat corners array): same data in ~0.5GB, zero per-vertex allocations. Corners stay in ascending-v order within a group, so the greedy crease clustering (and thus welded normals/topology) is bit-for-bit identical to the old weld. Output vertices are numbered in group (first-appearance) order rather than hash order -> geometrically identical, deterministically renumbered, and spatially coherent so meshopt compresses the merged GLB markedly better (469826: 130MB -> 83MB). Add tests/ngeom/test_weld.cpp: reproduces the old map-of-vectors weld verbatim as reference_weld() and asserts corner-by-corner parity (positions + normals + dedup count) across shared-edge/box/grid fixtures, plus crease semantics. NOTE: output vertex numbering changed -> golden-GLB byte tests need regold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native STEP->GLB stream held every parsed STEP statement of a giant shell resident during its single resolve: 469826's 61k-face monster piled parse_cache_ up to ~1.8GB, the whole conversion's memory floor. Resolver already has enable_parse_cache_bounding() (drops parse_cache_ every 1024 faces mid-shell, retaining the built ng:: geometry), gated single-threaded because the concurrent pool would force extra re-parsing through the shared StreamIndex. Phase A resolves each huge solid SINGLE-THREADED (only tessellate_doc's face pool is parallel), so enabling it on the phase-A resolver r0 satisfies that constraint; statement_bytes is pread-based (thread-safe, per-resolver scratch). Phase B's concurrent pool stays unbounded -- its solids are all below the huge threshold, so bounding there would be a no-op anyway. Measured on 469826 (nt=3, adaptive): peak RSS 2840 -> 1286 MB (-55%), conversion time unchanged, GLB geometrically identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same fix as the STEP->GLB path (previous commit), applied to the obj/stl streamer. step_to_mesh_stream.h's phase-A resolver resolved each huge solid without parse-cache bounding, so 469826's 61k-face monster piled parse_cache_ to ~1.8GB and OOMed obj/stl conversions (GLB was already fixed; mesh shares the identical phase-A structure but was missed). Enable bounding on the phase-A resolver r0 (single-threaded resolve there, so it's safe). Measured on 469826: obj peak 1262MB / stl 1300MB (were OOM), matching the GLB path's 1286MB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… shells) The IFC->GLB streamer's phase-A resolver had no mid-product bound: a single huge product (e.g. a 61k-face IfcClosedShell/IfcFacetedBrep) piled parsed Part-21 statements into cache_ during its one resolve, the IFC analogue of the STEP glb/mesh OOM already fixed. Add enable_cache_bounding() — drop cache_ (NOT the built surf_cache_) every 1024 faces, mirroring the STEP reader's enable_parse_cache_bounding — and enable it on the single-threaded phase-A resolver r0 (phase B stays unbounded, bounds per-product via clear_cache()). Refactor to make the bound reachable + safe: a shared iter_faces_bounded() helper (copies the face-ref list first, since the clear frees the parent shell Instance) used by add_shell_faces and IFCPOLYGONALFACESET; IFCADVANCEDBREP/ IFCFACETEDBREP now route through add_shell_faces instead of an inline loop. Behaviour is unchanged when bounding is off (default): same faces, same order. Verified: fixtures (surface-model / polygonal / faceted-brep / tessellated) all produce GLBs; 469826.ifc converts without OOM. (IFC peak on 469826 is still ~5GB — that file's memory is dominated by non-shell factors, not this bound; separate follow-up.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…model_scale) In the huge single-root face-parallel path of tessellate_one_root, tls_model_scale is thread_local and was set only on the calling thread. The spawned face workers started at 0.0 => adaptive density OFF => full fine tessellation for whatever share of the faces they grabbed. A huge solid's triangle count therefore SCALED WITH THREAD COUNT and varied run-to-run with scheduling (measured on 469826: 12.44M serial -> 14.6M at 4 threads -> 15.85M at 8). Publish the model scale on each worker so every face uses the adaptive density: tri count is now thread-invariant and deterministic (12,438,375 at 1/4/8 threads), and multithread output shrinks ~15-21%. Also batch the face-parallel tessellation + in-order merge (ADA_TESS_FACE_MERGE_BATCH, default 4096; 0 = old all-at-once) so the resident per-face local meshes never exceed one batch — on a 61k-face monster the full locals[] is a second complete copy of the solid soup (~0.5 GB). Byte-identical output (same face-order append). Defensive memory hygiene; not the dominant term in the 469826 obj/stl peak (that is fork/arena RSS in the worker, conversion intrinsic ~1.3 GB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… pressure) The audit's speed regression was root-caused (node PSI + per-cgroup io.stat) to nvme write saturation: with two worker replicas + garage on one disk, util sat >85% for ~29% of a run, stalling lightweight conversions 10-52s (files that convert in ~0.5s in isolation). Attribution over a heavy window: worker spill = 72% of disk writes (50 MB/s), garage = 28% (20 MB/s). GlbSpillWriter wrote every material's baked verts/indices straight to per-material temp files, so even a tiny model created + wrote disk files. Buffer each material's pos/idx in RAM and only spill to a file once the LANE's total in-RAM bytes cross a threshold (ADA_GLB_SPILL_THRESHOLD_MB, default 96; 0 = always spill). Below it a model stays entirely in memory and does ZERO spill writes — the common case, since the corpus is mostly small/medium models. A huge solid/dense model still spills and streams from disk, so peak RAM stays bounded (lane-total cap, not per-material, so a many-material model can't pile up N thresholds on the 6Gi worker). The merge reads each material from its buffer or file via MatLane::for_idx/for_pos. Verified byte-identical GLB (nt=1 deterministic order) across in-RAM / force-spill / threshold-crossing: md5 95e853d4b075 for 469826 all three ways. ngeom suite green. Follow-up: same buffering for the STEP->obj/stl MeshLane spill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two placement/relationship gaps in IfcResolver that made corpus IFC4x3 files render wrong: - IfcLinearPlacement (linear referencing along an alignment) was ignored — object_placement only handled IfcLocalPlacement, so every product on a linear placement collapsed to the origin (all geoms stacked). Consume the optional CartesianPosition (arg 2) — the exporter's pre-computed cartesian frame equivalent — which places them correctly (verified: the 25 signal products in linear-placement-of-signal.ifc now spread ~933 m along the alignment instead of at 0,0,0). Full IfcPointByDistanceExpression evaluation (no CartesianPosition) is a follow-up; identity there keeps prior behaviour. - IfcRelVoidsElement (openings/recesses) was never applied: the host solid wasn't cut and the IfcOpeningElement was rendered as a standalone positive solid. Build a voids map in build_rel_maps, exclude opening elements from proxy_roots, and in resolve_product subtract each opening's solid from the host via the existing BooleanN(difference) path — moving the opening into the host's local frame first (inverse(host placement) * opening placement). Frame-placed openings (extrude/revolve/sweep) + nested booleans are cut; face-set openings are skipped (no single frame). Verified on slab-openings.ifc: 1 slab with its opening + recess cut (was slab + 2 solid boxes), matching ifcopenshell's object count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mpty-scene tanks) Storage_Tanks_*.stp declare SI_UNIT(.EXA.,.METRE.) — exa-metre — as their length unit. The reader honoured it, scaling sane ~258 m coordinates up to ~2.5e20 m, so the geometry landed astronomically far from the origin and the viewer showed an empty scene. No real CAD file uses a base unit of MEGA-metre or larger; the Python stream reader already only recognises micro..kilo (_SI_PREFIX_SCALE) and defaults everything else to 1.0, which is why the Python serializer renders these files fine. Match it: si_prefix_factor now handles only KILO/DECI/CENTI/MILLI/MICRO/NANO and treats any other prefix as unprefixed metres. Verified: Storage_Tanks_01.stp bbox center 2.5e20 -> 257.5 m, extent -> ~10-33 m (a tank farm), 5975 solids; all four storage-tank files share the same unit and are fixed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
👋 Hi there! I have checked your PR and found no issues. Thanks for your contribution! PR Review:I found no pr-related issues.
Python Review:I found no python-related issues. Python Linting results:
Python Packaging results: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Companion to adapy's lazy blob-backed shape store (Krande/adapy#235). Beyond the original zero-copy buffer work, this branch grew a complete pure-C++ IFC reader and writer (no ifcopenshell / OCC), native IFC→GLB, and a batch of streaming-tessellation performance fixes shared by the STEP and IFC paths.
Zero-copy NGEOM streaming
StepNgeomStream.__next__/ the newIfcNgeomStream.__next__return a capsule-owned read-only numpyuint8array over the encoded buffer instead ofnb::bytes— removes the per-solid memcpy.shrink_to_fitat yield time drops the encoder vectors' growth slack so retained buffers are exact-size.tessellate_streamaccepts any buffer-protocol object (bytes, memoryview, the streams' numpy arrays) viaPyObject_GetBuffer, so a stored blob reaches the kernel with zero copies end-to-end.IfcNgeomStreamgives IFC the same per-product streaming entry the STEP path has.Pure-C++ IFC reader (
IfcResolver) — no ifcopenshell / OCCResolves IFC products to NGEOM directly, targeting full geometry coverage:
IfcFace; extruded / revolved / swept-area solids;IfcSweptDiskSolid,FixedReferenceSweptAreaSolid,SectionedSolidHorizontal; CSG (booleans + half-spaces); parametric profiles incl. I-shape fillets, derived profiles, rounded-rectangle corners.GL_LINES(alignment axes, trimmed/composite/segmented curves); alignment directrixes sampled closed-form (clothoid + cant).IfcStyledItem→ RGB), spatial-structure paths (instance_paths) for the scene tree,GlobalId-based picking whenNameis empty (matches the ifcopenshell GLB), gzip-compressed input.Pure-C++ IFC writer (
blobs_to_ifc) — analytic, no tessellationInverse of the reader: emits analytic solids from NGEOM blobs + metadata (extrude/revolve/sweep, swept-disk, CSG sphere), a
NEXT_ASSEMBLY_USAGE_OCCURRENCE/ spatial-zone hierarchy, andIfcStyledItemcolour — a lossless STEP↔IFC geometry + hierarchy round-trip.Native IFC→GLB
stream_ifc_to_glb: pure-C++ IFC → GLB, parallelized (LPT-ordered, per-thread resolvers), sharing the GLB spill writer + libtess2 with the STEP path.Tessellation / streaming performance
ADA_GLB_SPILL_THRESHOLD_MB, default 96) — small/medium models write no spill files; huge models still spill + stream, peak RAM bounded. Byte-identical output.model_scalefix: spawned face-worker threads had adaptive density off, so a huge solid's triangle count scaled with thread count and varied run-to-run; publishing the scale per worker makes it thread-invariant + deterministic.parse_cache_bounding on the phase-A resolver (STEP→GLB and STEP→mesh) and mid-productcache_bounding inIfcResolver— bound peak RAM on huge single-product shells.weld_mesh(drops the map-of-vectors), B-spline-weighted LPT cost estimate, moderate malloc mmap/trim threshold, per-root weld + crease-angle smooth normals.In-browser wasm (OCC-free, no pyodide)
OCC-free embind modules published in the wasm-base image: IFC→GLB (
adacpp_ifc_glb) and a B-rep writer (STEP↔IFC,adacpp_brep_writer).Misc
instance_pathsandSWEPT_DISK_SOLID→SweepN; circle-profile density from the angular tolerance.mesh_spike_stats/Mesh.spike_stats), Python-exposed.Tests
NGEOM unit suite green; GLB spill parity verified byte-identical across in-RAM / force-spill / threshold-crossing; native IFC reader/writer round-trips validated against ifcopenshell.