Skip to content

feat: pure-C++ IFC reader/writer, zero-copy NGEOM streams, tessellation perf#35

Open
Krande wants to merge 57 commits into
mainfrom
feat/lazy-shape-store
Open

feat: pure-C++ IFC reader/writer, zero-copy NGEOM streams, tessellation perf#35
Krande wants to merge 57 commits into
mainfrom
feat/lazy-shape-store

Conversation

@Krande

@Krande Krande commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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 new IfcNgeomStream.__next__ return a capsule-owned read-only numpy uint8 array over the encoded buffer instead of nb::bytes — removes the per-solid memcpy. shrink_to_fit at yield time drops the encoder vectors' growth slack so retained buffers are exact-size.
  • tessellate_stream accepts any buffer-protocol object (bytes, memoryview, the streams' numpy arrays) via PyObject_GetBuffer, so a stored blob reaches the kernel with zero copies end-to-end.
  • IfcNgeomStream gives IFC the same per-product streaming entry the STEP path has.

Pure-C++ IFC reader (IfcResolver) — no ifcopenshell / OCC

Resolves IFC products to NGEOM directly, targeting full geometry coverage:

  • Advanced B-reps, face sets, surface models, plain IfcFace; extruded / revolved / swept-area solids; IfcSweptDiskSolid, FixedReferenceSweptAreaSolid, SectionedSolidHorizontal; CSG (booleans + half-spaces); parametric profiles incl. I-shape fillets, derived profiles, rounded-rectangle corners.
  • Curve-only bodies → GL_LINES (alignment axes, trimmed/composite/segmented curves); alignment directrixes sampled closed-form (clothoid + cant).
  • Presentation colour (IfcStyledItem → RGB), spatial-structure paths (instance_paths) for the scene tree, GlobalId-based picking when Name is empty (matches the ifcopenshell GLB), gzip-compressed input.

Pure-C++ IFC writer (blobs_to_ifc) — analytic, no tessellation

Inverse 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, and IfcStyledItem colour — 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

  • GLB spill lanes buffered in RAM up to a lane-total threshold (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.
  • Thread-local model_scale fix: 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-product cache_ bounding in IfcResolver — bound peak RAM on huge single-product shells.
  • CSR grouping in 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

  • STEP reader reads the NAUO product tree into instance_paths and SWEPT_DISK_SOLIDSweepN; circle-profile density from the angular tolerance.
  • C++ mesh distortion scanner (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.

Krande and others added 3 commits July 3, 2026 21:12
- 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>
Krande and others added 26 commits July 4, 2026 12:04
- 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>
Krande and others added 23 commits July 9, 2026 20:30
…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>
@Krande Krande changed the title feat: zero-copy NGEOM stream buffers and IfcNgeomStream feat: pure-C++ IFC reader/writer, zero-copy NGEOM streams, tessellation perf Jul 13, 2026
Krande and others added 2 commits July 13, 2026 10:15
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>
@github-actions

Copy link
Copy Markdown

👋 Hi there! I have checked your PR and found no issues. Thanks for your contribution!

PR Review:

I found no pr-related issues.

  • ✅ PR title is ok
  • ✅ Release label is ok
  • ✅ SOURCE_KEY is set as a secret
  • ✅ Calculated next version: "0.13.0"

Python Review:

I found no python-related issues.

Python Linting results:

  • ✅ Isort
  • ✅ Black
  • ✅ Ruff

Python Packaging results:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant