feat: analytic FEM→CAD export across step/ifc/xml with native-first geometry and produced-file parity#245
Merged
Merged
Conversation
…rate hole
A face's ACIS loops are a linked list (loop.next at chunks[6]). When a degenerate "hole"
loop — a single zero-length, curve-less coedge marking a surface singularity, whose two
vertices are the same point — is ordered ahead of the real "periphery" boundary,
get_face_bound read ONLY that first loop, returned an empty wire, and the whole plate
failed to build ("build_advanced_face: wire build failed"). That silently dropped valid
hull-skin plates from Genie-XML -> STEP/IFC (the audit xml->step failures on
OP1_v1007_hullskin plus two Utror_6k models).
Walk the loop chain and take the outer boundary: the periphery loop if one is marked,
else the first loop that actually carries edges. Single-bound behaviour for normal faces
(first loop already the periphery) is unchanged.
Regression test: degenerate_first_loop.sat is the minimal 28-record closure of face
FACE00000604 from Utror_6k_pl_v2005; the test asserts the first loop is an empty hole and
get_face_bound now returns the real 4-edge periphery (0 edges -> fails without the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
🚀 Profiling Results (Top 20 most expensive calls)
|
Pins _bspline_points against an independent scalar de Boor reference (a copy of the original per-point loop) plus a hand-computed quadratic Bézier golden and a rational quarter-circle (every sample on the unit circle). Guards the upcoming vectorisation so plate geometry stays byte-for-byte equivalent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…oints) _bspline_points sampled the curve with a pure-Python per-point loop calling _de_boor (the SAT/Genie-XML read's hot path — ~4.7s of _de_boor across 338k calls in a single Utror_6k import). Evaluate every sample point in one batched numpy de Boor pass instead: knot spans via a vectorised searchsorted, then the de Boor triangle over the sample axis. Same arithmetic, byte-for-byte-equivalent output (test_bspline_points_equivalence; audit_repro 89987 writes the identical 8599790-byte STEP). On that Utror import the sampler drops from ~7.0s to ~1.2s cumulative (~6x) and _de_boor leaves the hot path. _de_boor is retained as the scalar reference the equivalence test checks against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…ncept peak) parity_for_fem_file counted its baseline by materialising every Beam/Plate concept (create_objects_from_fem(merge=True)) — the memory peak that OOMed the cross-format parity cell on the large FEM audit models. Count the baseline by STREAMING the same FEM-fused object pass the exporters consume instead (one transient object at a time), and export each format from a freshly reloaded mesh-only assembly so no writer's materialisation leaks into another format's count. To make all three streaming exporters actually fuse from the mesh at merge_strategy=None (the baseline's strategy), close two gaps: - Genie XML streamer now fuses Beam/Plate straight from the FEM mesh on the merge_strategy=None path (registering the fem sections/materials/thicknesses the fused concepts reference), and disables embed_sat when a part fuses (no Plate objects exist to build the shared ACIS body from). - IFC streamer now fuses beams from line elements independent of shells, so a beam-only (or mixed) FEM part no longer drops its beams. _unrepresentable_reason recognises FEM-fused Beam/Plate so a FEM source is not falsely skipped for Genie XML. Verified: shell-only, beam-only, and mixed shell+beam FEM sources all round-trip source==ifc==xml==step with no mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The streaming AP242 writer's object loop passed only name= to add_extrusion / add_brep / add_baked_instances, so every streamed object (FEM-fused or built) landed FLAT under the root PRODUCT with no assembly tree. The writer already had the machinery (_register_asm_path / _emit_component build the nested PRODUCT + NEXT_ASSEMBLY_USAGE_OCCURRENCE tree from a parent_path breadcrumb) — it was just never fed one on this path. Build each object's parent_path from its owning Part chain (root-first (id, name) levels, up to but excluding the root product) and thread it through add_extrusion (new parent_path kw), add_brep, and add_baked_instances. A nested Assembly/Part/Part/member model now round-trips its full parent hierarchy through the streamed STEP's NAUO tree instead of collapsing to one flat level. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…eam writer
The streaming STEP writer dropped the CSG primitive solids it couldn't author
as a shell: PrimSphere.solid_geom() is an ada.geom Sphere (not a ClosedShell),
PrimCone a Cone — so _emit_brep_geometry's analytic path returned None and the
sphere/cone were skipped. A sphere/cone has an EXACT analytic B-rep, so
tessellation is not acceptable here ("all should emit analytical repr").
Add a shared kernel-free converter (ada.geom.primitive_brep) that builds the
primitive's exact analytic B-rep as an ada.geom ClosedShell of AdvancedFaces the
existing _brep_surface path already emits:
- Sphere -> one SPHERICAL_SURFACE face bounded by a single pole VertexLoop
(new ada.geom.curves.VertexLoop + VERTEX_LOOP writer support).
- Cone -> a CONICAL_SURFACE lateral face (seam generatrix + base circle split
into two semicircle arcs so the shared base edge's sense is unambiguous) plus
a planar cap.
An adacpp-native fallback (build -> write_step -> stream-read the analytic faces)
covers primitives the pure-Python track can't yet do (Torus, ...), still analytic
never faceted.
_emit_brep_geometry is now a buffered tiered dispatch — analytic shell, then
pure-Python primitive, then adacpp-native, and only a genuinely non-analytic
swept solid reaches the faceted path. Each tier writes into a fresh buffer rolled
back on failure, so a partial write from a tier whose Nth face is unsupported is
discarded before the next tier runs.
A PrimBox/PrimCyl/PrimCone/PrimSphere assembly now emits all four as watertight
analytic solids (4 valid, 0 invalid on OCC read-back); the whole sphere is ONE
spherical face, not a triangle soup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…ents adapy is a public code repo, so committed comments/docstrings must not carry client model identifiers or private-repo internal paths. Replace them with generic descriptions (comments only — no code/behaviour change): - client model filenames -> generic geometry descriptions (hull-skin / jacket / ship / Abaqus model, etc.) - private-repo planning/spec doc paths -> "the internal design/planning notes" - absolute local machine paths -> generic "<path>" / "the repo root" Kept: generic geometry terms (hull-skin, jacket), and "forgejo"/"github" which are legitimate configurable VCS-target values in the audit-issue-bot feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…per-element) iter_objects_from_fem(merge_strategy=None) — the pass the cross-format FEM parity and STEP/IFC streaming exporters run — built one Plate per shell element through the SCALAR CurvePoly2d.from_fem_shell, so a 46k-element mesh (aba_col) spent ~30s in 66k from_fem_shell calls plus ~10s in 116k per-element compute_orientation_vec. The batched builder existed (convert_part_shell_elements_to_plates via CurvePoly2d.from_fem_shells_batch) but was only wired to the non-streaming create path and the non-None merge strategies. Extract the per-element gather (shell_elem_to_plate_entries) and the per-arity batch build (build_plates_from_entries) as shared helpers — both mirror the scalar convert_shell_elem_to_plates semantics exactly (section guards, material consolidation, warped-quad->2-triangle split, tri-branch material quirk + try/except) — and add a chunked streaming generator (Part._iter_scalar_plates_ from_fem) that yields plates in bounded chunks so peak memory stays flat. Wire iter_objects_from_fem(merge_strategy=None) to it. Output is bitwise-identical to the scalar path (verified over aba_col's 66k outlines: max coord diff 0.0, 0 escapes). aba_col iter_objects_from_fem drops from 39.0s to 15.6s (2.5x); compute_orientation_vec is gone entirely. Remaining cost is per-Point/Node object construction inside from_fem_shells_batch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The cross-format FEM parity ran the concept/NGEOM stream FOUR times: a dedicated
_streaming_baseline_count pass, then one pass each inside the ifc/xml/step
streaming writers. The three file-writers each need their own pass (they emit
different files and can't share a single generator without materialising the
whole concept set, which would break the bounded-memory guarantee), but the
separate baseline pass is pure redundancy: write_step_stream already returns
{emitted, skipped} whose total IS the baseline concept count (every fused object
is emitted or skipped).
Run STEP first when it's among the requested formats and take its stream total
as the baseline — dropping the 4th full pass entirely (a ~25% cut in concept-
stream passes, ~15s per pass on the 46k-element aba_col). A dedicated streaming
count remains as the fallback when STEP isn't exported or errors. Parity results
(counts + consistency) are unchanged on shell/beam/mixed FEM models.
Full one-pass-feeds-all-three-writers sharing was NOT done: the writers stream
to independent files and a shared generator would require materialising the
concept set (unbounded memory), so it is structurally at odds with the bounded-
memory requirement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The flatten walks the whole tree from the assembly root, so a non-root part is already in the list; appending it again on include_self=True duplicated it. Every FEM consumer that iterates these parts then processed that part's shell mesh twice, yielding duplicate faces (and duplicate concept names on the analytic export). Only add self when the walk didn't already include it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The coplanar merge folds a FEM shell mesh into flat <flat_plate> polygons, so a curved skin (a tubular member) arrives as thousands of tiny per-plane facets and the XML balloons. FEM->STEP/IFC already avoid this with the analytic face source (recognised cylinders + coplanar- merged planes); this brings the same to Genie XML. The streaming XML writer now accepts the analytic strategies (surface/panel/cylinder/analytic). It sources the strong analytic merge (iter_fem_analytic_faces, tagged with each face's material+thickness via a new backward-compatible with_meta mode) and authors each curved patch into the embedded ACIS body, referenced by a <curved_shell>; planar patches stream as compact <flat_plate> polygons. A cylinder is re- expressed as a degree-1 B-spline surface (the SAT reader carries only plane/spline surfaces) with a UV pcurve per boundary edge, so the read- back face tessellates as a curved surface instead of collapsing to a flat sliver. embed_sat now composes with the analytic strategy and is turned on by default for it. No face is ever dropped: a patch that can't be authored falls back to its boundary polygon. On a ~41k-element shell model the analytic XML is 14 MB vs 31 MB coplanar, with the curved members round-tripping back to spline-surface plates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The streaming FEM->XML export defaulted to "coplanar" (flat polygons);
switch it to the analytic auto-detect ("cylinder"), matching FEM->STEP
and FEM->IFC. Tubular members now export as <curved_shell> over an
embedded SAT body and flat panels as merged <flat_plate>, collapsing a
tube's shell facets instead of emitting thousands of coplanar polygons.
A string merge_fem_objects still overrides verbatim and False opts out
to the 1:1 element->plate form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The CI lint gate (black --check) flagged the two new files from the analytic curved_shell feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
test_analytic_cylinder_ifc_streaming_roundtrips asserted the re-imported tube tessellates to >100 triangles. That threshold only held because get_all_parts_in_assembly used to duplicate a non-root part, emitting two overlapping cylinders (~2x104 on OCC). With that duplication fixed the tube is a single trimmed CYLINDRICAL_SURFACE — ~52 tris (OCC) / ~144 (adacpp), still a real curved mesh. Lower the guard to >40 (a dropped/degenerate face would be a handful) so it checks "renders curved" without encoding the bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The per-face coverage counters only fired on the connected_face_set path. face_based_surface_model and shell_based_surface_model dropped faces via a bare `except: continue` and never touched FACE_STATS, so their faces were uncounted entirely — a face-based surface model reported total=0, which the run summary rounds up to "100% coverage" (the same green-check-on-zero-faces hole the module already guards on the shell path). Route all three face-set paths through one _count_mapped_face helper so a dropped OR merely un-counted face is always tallied. serialize_geometries also skipped whole unmappable root geometries silently; add a ROOT_STATS/ROOT_DROP_REASONS counter (consumed alongside the face counters and surfaced on the drop-reason channel as `root: <type>`) so an unmapped solid is flagged instead of vanishing from the stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Replace the FEM cross-format parity's re-derive-with-merge_strategy=None +
entity-count-equality design with a geometry invariant computed over the
ALREADY-PRODUCED output blobs.
The old design had two bugs. It re-derived the source to ifc/xml/step with
merge_strategy=None (one plate per FEM element) and compared entity counts for
equality — validating an UNMERGED model production never ships (production ships
the analytic cylinder model, collapsing ~100k plates to a handful of cylindrical
faces). And the None re-derivation wrote ~1 GB of temp files per model, stalling
the audit worker on nvme write-contention. Entity-count equality also cannot
hold under the analytic model: one tube is a single CYLINDRICAL_SURFACE in STEP
but several SAT faces in Genie XML, so counts legitimately differ.
New: parity_from_produced_files(source_key, produced) loads each produced blob
(mesh formats directly; step/ifc/xml tessellated via the production to_gltf path)
and compares a format-agnostic geometry measure. The strict gate is the
BOUNDING-BOX extent (2%): it is representation-independent — a mid-surface, a
thin solid and a line-beam of one member all span the same extent — so a dropped
solid / region / empty output shows in every format, with no false positive from
the fact that adapy's writers emit different DIMENSIONAL representations (STEP a
plate mid-surface, Genie-XML a thin solid ~2x the area, glb a mesh with zero-area
line beams). A coarse surface-area FLOOR on the CAD/structural trio backstops a
gross loss that somehow preserved the bbox; absolute area is deliberately NOT a
strict gate because the solid-vs-surface split makes it non-comparable.
ParityResult is kept shape-compatible (counts now carry per-format
{area,bbox,tris} measures; expected is the reference triangle count). The old
count-based FEM helpers (parity_for_fem_file, _streaming_baseline_count,
_count_format_entities, _PARITY_MERGE_STRATEGY=None) are retired; the offline
parity_for_source_file fallback now derives the PRODUCTION cylinder outputs and
compares the same invariant, so local and audit agree. STEP sources keep the
streaming instance-count fast path (never the memory/temp-file problem).
Tests updated: the FEM count-equality assertions are replaced with
geometry-invariant checks (the formats' areas legitimately differ by
representation), plus deterministic verdict tests pinning the bbox gate, the
area floor, the empty-format flag, missing-format skip, and — critically — that
the solid-vs-surface area gap does NOT false-positive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Rewire the audit worker's parity cell (target_format=='parity') to the geometry-invariant path. For a FEM source it now fetches each already-produced output blob (step/ifc/xml/glb — converted+uploaded earlier in the run with the production strategy; the auto-validate poller enqueues parity only after every conversion cell for the source has landed, so the blobs exist) to a worker-local tempfile before forking, then the OOM-isolated child calls parity_from_produced_files. A missing blob (its conversion failed/was skipped) is recorded, never re-derived. This does zero re-conversion, so it no longer stalls on nvme write-contention writing ~1 GB of temp files, and it validates exactly what ships (the analytic cylinder model) rather than an unmerged model. Blobs are streamed via the existing storage.stream_to_path helper (the same one the source download uses) and passed to the forked child as filesystem paths (the fork shares the fs); the produced tempdir is cleaned up in a finally once the child has read them. Non-FEM (STEP/IFC/SAT) sources keep the streaming/whole-model re-derive path (parity_for_source_file in the child): those were never the hang, and their Genie-XML output is legitimately empty for a raw-solid source, which that path correctly SKIPS rather than flagging as dropped geometry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The kernel-free STEP stream reader read CONICAL_SURFACE.semi_angle and
conic TRIMMED_CURVE parameter trims as raw literals, assuming radians. STEP
tags every plane angle in the unit its GLOBAL_UNIT_ASSIGNED_CONTEXT declares,
which is commonly degrees (CONVERSION_BASED_UNIT('DEGREE')). A degree-context
cone whose semi_angle literal is e.g. 1.5 was built as 1.5 rad (~86 deg) — a
near-degenerate flat cone that libtess2 meshes to zero triangles, dropping the
face; steeper cones (literal 45 = 45 deg) built as 45 rad, the wrong shape.
Detect the context's plane-angle unit factor (radians per unit) from the
representation context — exact from the referenced PLANE_ANGLE_MEASURE_WITH_UNIT,
name-based fallback otherwise — and apply it to cone semi_angle and to numeric
conic trim parameters. Radian-context files resolve to 1.0 and are unchanged.
On a medium curved-CAD assembly this recovers every dropped cone face
(native+libtess2 cone coverage 234/270 -> 270/270) and corrects the shape of
the rest, so the native path no longer needs the OCC fallback for these.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Cover the degree/radian plane-angle unit detection (context scan + via a representation's context), the cone semi_angle and conic trim scaling, that line trims (length parameters) are left untouched, and the detect->resolver->builder wiring the two-pass reader uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
CI lint gate (black --check) flagged the new test file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
The parity redesign changed _parity_child(..., formats=...) to _parity_child(..., produced=...); this test still passed the retired `formats` kwarg (it lives outside the visual_parity test dir the redesign updated). Drop the kwarg so the child takes the offline re-derive path (parity_for_source_file) — for a Genie-XML source that stays the count-based cross_format_parity, so the existing count assertions still hold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
A solid-only / mesh-only FEM has no shells or beams to reconstruct, so its
step/ifc/xml exports are legitimately empty while the glb still carries the
element mesh. The geometry-invariant parity flagged every such source as a
mismatch ("produced no renderable geometry"), erroring ~all FEM parity cells.
Now empty structure-preserving formats are recorded as skipped, the gate
compares only formats that carry geometry, and a source whose formats all
agree there is nothing to render is consistent. An empty glb while concepts
carry geometry is still flagged as a real loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
…ept area The gross-loss area floor used the max concept area as its reference. On a shell FEM the Genie-XML export tessellates thick solids to ~5x the mid-surface area STEP/IFC carry, so the max was that one inflated format and the floor (0.34x max) falsely flagged the correct mid-surface STEP/IFC as "grossly dropped" — even though the bbox agreed across all formats. Reference the floor off the MEDIAN concept area instead: robust to one inflated representation while still catching a genuinely near-empty format. Regression test mirrors a real shell-FEM cell (bbox agrees, xml area 5x). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Re-tessellating a produced STEP/IFC blob to measure it can hard-crash (a native SIGSEGV in the adacpp tessellation — confirmed on the two largest FEM models, and reproduced in BOTH the OCC and the native stream paths, so it is a pathological writer/reader roundtrip, not a single bad path). A SIGSEGV can't be caught in-process, so measure each CAD blob in a clean subprocess: a native fault dies there with a negative return code, that ONE format is recorded as unmeasurable (skipped, logged), and the formats that DO measure decide the verdict. Uses a fresh subprocess, not os.fork(), which deadlocked under the test suite (a child inheriting a lock the parent held at fork time). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzmAkT3ZCoVJPE2R3MUGgh
Update the conda ada-cpp pins (adacpp-runtime + adacpp features) and the wasm-base image ARG to 0.17.1, and re-solve the lock. 0.17.1 is now indexed in conda-forge repodata across linux-64/osx-64/win-64. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
ada_cli.audit.ADACPP_DEFAULT_IMAGE was still 0.16.1 while the Dockerfile pin moved to 0.17.1; test_deploy_pins guards against exactly this drift. Bump it so audit sweeps validate the same engine the viewer serves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
… ring The curved plate-edge sampler dominated one large Genie-XML/SAT import (~27s of a 63s convert): _ellipse_points sampled the FULL ellipse/circle ring at 2048 points for every curved edge (~8500 of them), then _clip_to_endpoints discarded all but ~24 — and both the ellipse and B-spline samplers materialised every point one scalar float() at a time (a 69.6M-call genexpr, ~16s alone). Two fixes, output-preserving: - Recover each vertex's parameter angle on the ellipse and sample the short arc between them directly (_ellipse_arc_points), so we build ~n points on the actual edge instead of a dense ring that is immediately thrown away. For the small arcs a plate boundary traces, short-angle == short-arc-length, matching the old clip. - Convert sampled arrays with ndarray.tolist() (C-level) instead of a per-scalar float() genexpr, in both the ellipse and B-spline paths. Measured on the hull-skin import: the ellipse path drops from 11.7s tottime / 28.5s cumtime (plus its 15.7s genexpr) to 0.26s / 1.08s; read wall ~57s -> ~19s. _clip_to_endpoints stays for the open B-spline path (still sliced, never wrapped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…m_segments
A flat plate whose SAT boundary follows a circle/ellipse used to be sampled into ~24 straight
outline points at read time — slow (a full-model import spent most of its time in the sampler)
and lower fidelity (the analytic track then emitted a faceted polyline to IFC/STEP).
Instead keep the boundary analytic:
- New CurvePoly2d.from_segments + Plate.from_segments build the outline from an ordered list of
LineSegment/ArcSegment/SplineSegment, carried verbatim rather than sampled and rebuilt through
build_polycurve. curve_geom emits a real ArcLine, so OCC (Geom_Circle edge) and NGEOM (arc
sampled from 3 points) discretize downstream, and IFC/STEP emit an analytic arc (IfcArcIndex /
CIRCLE+EDGE_CURVE) with no read-side sampling.
- CurvePoly2d.build_edge_segments assembles ordered corners + per-edge PlateEdgeCurve specs into
that segment loop (endpoint-matched, winding-agnostic). New SplineSegment carries an analytic
B-spline for a follow-up (its downstream emit is not wired yet; spline plate edges are still
sampled for now).
- The SAT plate reader emits a PlateEdgeCurve("arc", midpoint) per circle/ellipse edge instead of
interior samples; the specs thread through the gxml store to the flat-plate build and, for
curved shells, ride along as fallback data so an OCC-failure flat fallback still draws real arcs
(materialized only if it fires — never sampled up front).
Removes the per-edge ellipse sampling from the hot import path; the arc is reconstructed downstream
from p1/midpoint/p2 on every consumer. tests/core green; adds tests/core/api/test_plate_from_segments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…urve method Two design cleanups on the analytic-plate-boundary work: - Replace the fragile string discriminator on PlateEdgeCurve (kind="arc"/"spline") with explicit ArcEdge (a, b, midpoint) and SplineEdge (a, b, curve) subclasses. build_edge_segments and the reprojection helper now dispatch on isinstance, and the SAT reader emits ArcEdge. ArcEdge/SplineEdge are the readers' declarative input DTO (undirected, endpoint-matched, cheap to reproject); build_edge_segments turns them into the directed ArcSegment/SplineSegment geometry. - Give BSplineCurveWithKnots a sample(n) method — the single home of the vectorised de Boor evaluator (rational subclass de-homogenised via weights_data). SplineSegment.sample delegates to it, and the SAT reader's _bspline_points is now a thin "keep-the-chord-on-failure" wrapper over it instead of a duplicate implementation. B-spline plate edges are still discretized at read for now; carrying them analytically through the outline needs the render/emit tracks wired for SplineSegment (2D-projected profile curve, OCC/NGEOM discretization, IfcCompositeCurve / STEP B-spline edge) — a separate follow-up. tests/core green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…ll tracks Completes the analytic-curved-boundary work: a flat plate whose SAT boundary follows a B-spline now carries the spline through as a real SplineSegment instead of being sampled into straight outline points at read time. The SAT reader emits a SplineEdge (the analytic ada.geom curve, no read-side sampling); build_edge_segments/from_segments turn it into a SplineSegment; each consumer discretizes or emits it downstream: - CurvePoly2d.from_segments projects the spline's control points into the plate's 2D local profile frame and snaps its (clamped) endpoints to the corner vertices so the outline wire closes. - curve_geom emits the spline into the IndexedPolyCurve. - OCC: make_edge_from_line builds a real Geom_BSplineCurve full-curve edge (analytic). - IFC: a spline-bearing outline routes to an IfcCompositeCurve (IfcBSplineCurveWithKnots for the spline, IfcPolyline/IfcTrimmedCurve for line/arc), since IfcIndexedPolyCurve is line/arc-only. - NGEOM (adacpp) + STEP + IndexedPolyCurve.get_unique: sample the spline into a polyline (a faceted but always-valid boundary; a true STEP B_SPLINE edge + SURFACE_OF_LINEAR_EXTRUSION face is invasive and easy to make an invalid B-rep, so it's a deliberate faceted fallback there). Sampler consolidation: the de Boor evaluator is BSplineCurveWithKnots.sample(n) (rational supported). The curved-shell OCC-failure flat fallback drops splines to a chord (a spline lives on the curved surface; forcing it onto the flat best-fit plane produces tessellation slivers) — arcs still reproject analytically. Hull-skin read now ~9.5s (was ~60s). tests/core + the ada-cpp backend suite green; adds synthetic spline-plate coverage across OCC/IFC/NGEOM/STEP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
The ap242 streaming writer sampled a B-spline plate boundary into short line segs -> a fan of planar side faces. Emit the real analytic face instead: a SURFACE_OF_LINEAR_EXTRUSION swept from the boundary B_SPLINE_CURVE_WITH_KNOTS along the extrusion vector, with B-spline EDGE_CURVEs top and bottom. The profile spline is clamped with its endpoints snapped to the corners, so its first/last control points are the edge vertices (the validity precondition an analytic B-spline face needs). - Seg gains a `spline` field; _reverse mirrors the B-spline (control points/weights/knots) so a loop the winding forces to reverse stays oriented start->end with same_sense=.T. - _curve_to_segs emits a "spline" Seg; _build_loop builds the swept side face; new _bspline_edge / _bspline_basis_from_cps emit from already-lifted world control points (unlike _bspline_curve which lifts 2D profile points). The planar end caps reuse the B-spline boundary edges unchanged. Gated by an OCC round-trip: test emits a spline plate, reads it back (active_backend.read_step_bytes), and asserts one valid solid + exactly one SURFACE_OF_LINEAR_EXTRUSION + 6 faces (no facet fan). Verified valid under both OCC and the adacpp reader; reversed-winding and rational splines round-trip clean too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…e-curve read-back The streaming IFC writer sampled a B-spline plate boundary into an IfcIndexedPolyCurve. Emit the same analytic form the normal writer uses: an IfcCompositeCurve (line -> IfcPolyline, arc -> IfcTrimmedCurve on IfcCircle, spline -> IfcBSplineCurveWithKnots), hand-authored as SPF text. Also fixes a latent read gap the normal writer's composite curves (from the earlier commit) exposed: CompositeCurve had no to_points2d, so a spline plate read back as a generic shape instead of a Plate. Add CompositeCurve.to_points2d (line endpoints, sampled arcs/B-splines) so ada reconstructs the plate. The IFC file itself stays analytic; only this reader-side outline samples. Verified with ifcopenshell.validate (schema + where-rules): both the normal and streaming spline-plate IFCs report ZERO issues and carry METRE units; both round-trip through ada.from_ifc as Plates. Note: ifcopenshell's geometry engine cannot currently tessellate a B-spline IfcCompositeCurve profile at the product level (an upstream engine limitation, not a validity issue) — the file is valid analytic IFC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…immed) The B-spline composite-curve segment must be a BARE IfcBSplineCurveWithKnots. Wrapping it in an IfcTrimmedCurve makes ifcopenshell's geometry engine build a wire from it, but a B-spline is already a bounded curve, so trimming it violates the EXPRESS rule IfcTrimmedCurve.NoTrimOfBoundedCurves — caught by ifcopenshell.validate(express_rules=True). The bare form is valid IFC and round-trips through ada into a valid OCC solid (ada's OCC harness, same as the STEP path). Known limitation: ifcopenshell's own geometry kernel can't currently tessellate a B-spline IfcCompositeCurve profile at the product level (an upstream engine issue, NOT an IFC validity problem). adapy's reader samples the composite back to a Plate outline, so adapy renders it either way. Also: _trim_select now wraps a parameter trim as an IfcParameterValue (correct IfcTrimmingSelect), and CompositeCurve.to_points2d reads a param-trimmed B-spline basis. The streaming spline test now asserts EXPRESS-rule validity + the ada OCC harness (no invalid trim wrapper). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
New `ifc-validation` pixi feature/env + `scripts/validate_ifc.py` + `ifc-validate` task that run the offline, installable parts of the buildingSMART IFC Validation Service (https://github.com/buildingSMART/validate) against one or more IFC files or directories: - Syntax check — ifcopenshell.open + C++ core error capture - Schema check — ifcopenshell.validate (attributes/selects/inverses) - EXPRESS where/global rules — ifcopenshell.validate(express_rules=True) - IDS (opt-in) — ifctester when an .ids is passed Non-zero exit on any error, so it is CI-usable: `pixi run -e ifc-validation ifc-validate <path>`. Kept conda-only (ifctester/pypi ifcopenshell has no manylinux wheel and would break the solve — enable IDS via `pip install --no-deps ifctester`). The Gherkin normative-rules pillar is a heavy non-pip git repo and is documented for separate use, not wired in. Verified: adapy's normal + streaming writers both PASS on a B-spline plate model. (The validator also surfaces a separate, pre-existing adapy issue on beams — a double IfcRelAssociatesMaterial violating IfcBuiltElement.MaxOneMaterialAssociation — to be fixed separately.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
… usage) The generic material post-step added every physical object to the bare per-material IfcRelAssociatesMaterial — including beams, which already carry an IfcMaterialProfileSetUsage association from add_material_assignment. Two material associations violate the EXPRESS where-rule IfcBuiltElement.MaxOneMaterialAssociation (caught by ifcopenshell.validate / the ifc-validate task). Skip beams in the bare rel; their material still round-trips via the usage (get_associated_material resolves ForProfileSet.MaterialProfiles, and the beam reader takes mat_ref.Material.Name). Also de-dup the rel extension — pipe segments were appended both directly by write_ifc_pipe and by the post-step, and RelatedObjects is a SET. Adds tests/core/cadit/ifc/test_ifc_validity.py: both writers must pass schema + EXPRESS validation on a beam+plate model, with exactly one material association per element and the beam's being its profile-set usage; beam material/section round-trip guarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…s in ifcopenshell) A B-spline IfcCompositeCurve profile is valid IFC but ifcopenshell's geometry engine cannot build a wire from it, so third-party viewers rendered nothing. Emit the exact analytic B-rep instead: - ada.geom.primitive_brep.extruded_loop_to_shell: kernel-free ClosedShell of AdvancedFaces for an extruded boundary loop — planar caps and sides, cylindrical side faces for arc edges, and the spline side face as the EXACT degree-1-in-v B-spline surface of the linear extrusion. Loop normalized to CCW about the extrusion axis; topology mirrors the OCC-round-trip-proven AP242 streaming STEP emitter (shared boundary/connector edges, 4-edge quad side loops). - write_ifc_plate routes spline-boundary plates to IfcAdvancedBrep (extrusion body unchanged for everything else); the streaming writer sends them through the normal-writer preamble (the SPF text path is for the ~100k plain FEM plates). advanced_face writer gains CylindricalSurface support. - Topological instance sharing (EXPRESS rules compare by instance — IfcEdgeLoop.IsClosed failed on coincident-but-duplicate vertices): vrtx dedupes per file by coordinate, create_edge_curve by EdgeCurve object identity. Caches store entity IDs, not instances — an entity references its file, which would pin the WeakKeyDictionary key forever. - Parametric read-back: read_plates reconstructs the Plate from the B-rep (the spline surface's v-direction IS the extrusion vector -> caps -> bottom loop -> Line/Arc/Spline segments -> CurvePoly2d.from_segments), so from_ifc returns a real Plate, not a generic shape. - AdaCpp backend: _encode_curve samples B-spline profile segments into line records (its edge vocabulary is line/arc/circle; same policy as the NGEOM serializer). sync_added_physical_objects recreates a pruned per-material rel (beam-only materials leave it empty) so multi-sync survives. - Tests route OCC checks through active_backend (is_valid/volume) — run on both CAD backends. Verified: express-rule validation 0 issues, ifcopenshell's own engine renders bulge + arc, ada round-trips a parametric Plate with Line/Arc/Spline segments and exact thickness, both writers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…opped beams) Latest sweep had 5 parity failures; root causes were one measurement bug and one real drop: 1. glb=ERR (4 of 5 cells, geometry actually CONSISTENT): production GLBs ship EXT_meshopt_compression, which trimesh cannot decode (IndexError slicing the fallback-buffer layout). New ada.visit.gltf.meshopt.meshopt_decompress_glb unpacks to a plain GLB (decodes each compressed bufferView with the same codecs the packer verifies against, re-lays-out sequentially); the parity measurer sniffs the extension and unpacks before measuring. A missing codec maps to "unmeasurable" (skip), not a mismatch. Verified on the actual produced blob: identical tris/area/bbox to the uncompressed conversion. 2. step bbox short (the one real geometry drop): the analytic "cylinder" merge strategy in the FEM->STEP stream writer fused only SHELL elements — LINE (beam) elements were silently dropped (neither emitted nor counted skipped). The analytic branch now fuses beams via iter_objects_from_fem(beams=True, plates=False), same as the non-analytic branch. A beam+plate FEM repros [OK] (STEP bbox matches ifc/xml); a jacket model's STEP leg went from 0 m2 to parity with ifc/xml. Plus: `ada audit repro <id>` now handles parity cells directly — it re-produces every compared format locally with the production converter and runs the same produced-files parity check the worker runs, printing the verdict + per-format mismatch/error detail. Regression tests: fem->step cylinder keeps beams (MANIFOLD_SOLID_BREP present); meshopt-packed glb measures identically to uncompressed (adacpp-gated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
black on geom/curves.py (ruff-format drift the PR lint job flagged) + isort on the parity-fix files the next lint run would flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
An audit sweep converts each source to many targets, and the worker re-downloaded the source for every job — a 778 MB model was fetched up to 5x per run at ~15 MB/s (30-60 s of pure re-download per cell; one step cell paid 49.6 s of its 255 s total on fetch). New SourceBlobCache (src/ada/comms/rest/source_cache.py): disk LRU keyed by sha256(scope + key + version), version = the storage API's e_tag (a real store metadata call via Storage.head(), NOT a HEAD on a presigned GET; falls back to size+last_modified). A changed blob hashes to a different entry, so stale serves are structurally impossible. Entries are written temp+atomic-rename with a size-verifying .meta sidecar (torn writes read as a miss), handed to jobs by hard-link (eviction mid-job cannot yank the inode; the job's unlink drops only its link), and any cache failure falls back to the direct download. ADA_WORKER_SOURCE_CACHE_MB caps the cache (default 4096; 0 disables); hits/misses are logged and recorded as convert_meta["source_fetch"], so fetch_ms ~0 marks a hit. SIF-reduced and SIN range-stream paths (which mutate/partially read the source) bypass the cache as before. 12 new tests (hit/miss/version-change/eviction/truncation/fallback/scope isolation); full REST suite 252 passed. Multi-pod workers keep independent caches (one download per pod per version). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
ADACPP_BRANCH now points at feat/step-emit-mapped-instances (AP242 assembly instancing + the closed-B-spline-edge and periodic-domain tessellation fixes). Marker bump so the overlay is pulled fresh; the previous branch's commits are all contained in the pinned 0.17.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
… control
Genie-XML curved shells (thickness in the XML, face geometry in the embedded ACIS, direction from
the XML sense flag) exported as ZERO-thickness surfaces. They now export as analytic thick solids,
built entirely kernel-free so the whole pipeline (NGEOM serialize, adacpp tessellation, IFC, STEP)
runs without pythonocc — verified end-to-end in the no-OCC env.
- ada.geom.primitive_brep.face_to_thick_shell: bottom = the face's surface+bounds (translated per
anchor), top = the SAME surface rigidly translated by t*direction (B-spline control points moved
— exact, stays NURBS; a true offset surface would not), sides = one ruled face per boundary edge:
Plane for lines, exact degree-1-in-v ruled B-spline for spline edges, an exact rational-conic
ruled patch for circular arcs (a cylinder/extrusion side face is untrimmable by the libtess2
kernel — a boundary arc meshed as the whole tube; the arc patch's natural bounds ARE the ribbon),
SurfaceOfLinearExtrusion for the rest. Shared vertex/EdgeCurve instances, p-curves + SAT trim
params carried through. Anything unbuildable falls back to today's bare face — never dropped.
- Config().geom_thickness_anchor ("as_is" | "flipped" | "centerline", env ADA_GEOM_THICKNESS_ANCHOR)
applies to flat Plate extrusions AND curved thickening; as_is keeps flat output byte-identical.
Config().geom_thicken_curved_shells (default true, env) is the on/off switch.
- IFC: PlateCurved emits IfcAdvancedBrep (new IfcSurfaceOfLinearExtrusion writer arm + reader
inverse; shared-edge p-curves now attach only to the face whose BasisSurface they belong to;
IfcAdvancedFace.SameSense no longer dropped). STEP stream + NGEOM consume the same shell through
their existing surface arms. pyocc route converts the same geom shell (Geom_SurfaceOfLinearExtrusion
arm added); adacpp multi-face shells tessellate via the stream kernel instead of OCCT sewing
(sewing wrecks p-curve trims).
Hull-scale results (5462 curved shells, all thicken, ~0.6 ms each, zero fallbacks): ifc 10.0->31.3 s
/ 30.8->91.0 MB; step-stream 1.1->8.1 s / 25.2->73.9 MB; glb 4.0->19.2 s / 0.37M->2.25M tris.
IFC express-rule validation 0 issues and ifcopenshell's own engine tessellates the brep; OCC oracle:
STEP read-back valid, volume exact (t x projected area — rigid-translation sweep, Genie-style, not a
normal offset). Genie-XML round-trip is byte-identical thin vs thick (writer consumes the bare face +
t + sense flag). IFC read-back of a thickened shell imports as a generic B-rep shape (geometry
preserved); the bare-face round-trip stays available with the config off. The bare-face no-heal
regression test pins the config off, since its subject is the un-thickened path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
The thickened curved-shell export paid ~ms/face in the Python writers (per-entity ifcopenshell API / SPF text authoring). The geometry already serializes to NGEOM, and adacpp gained stream_ngeom_to_step / stream_ngeom_to_ifc record-stream emitters — so route the emit leg to C++: - New ada.cadit.ngeom.export: collect_ngeom_records walks the assembly's physical objects, serializes each solid_geom() to an NGEOM blob (colour + part-hierarchy paths carried), and native_to_stp / native_to_ifc hand the record stream to the adacpp emitters. Strict no-geometry-left-behind: ANY unsupported object aborts to the wholesale Python fallback. - Part.to_stp(writer="native") added; the native IFC writer emits non-ShapeProxy shapes by serializing their solid_geom(). The xml->step and xml->ifc converter legs try the native route first, default ON, gated by Config().cad_native_ngeom_export (env ADA_CAD_NATIVE_NGEOM_EXPORT). - Stream STEP reader: ARBITRARY_CLOSED_PROFILE_DEF builder (the C++ writer's extrusion profile form; the python writer never emitted it). Hull benchmark (5,470 objects / 34,085 faces, thickening ON): to_ifc 34.4 s -> 6.6 s (faster than the 10.0 s THIN python export; C++ emit itself 1.2 s, ~25x per-writer), to_stp 8.5 s -> 6.8 s. Coverage 5470/5470 native, 0 skipped, 0 dropped. Remaining native wall is the thickening + Python-side NGEOM encode, not the writers. Verified: STEP OCC read-back valid; stream-reader counts identical to the python writer (5470, names preserved); ada.from_ifc round-trip loads all 5470. Note: ifcopenshell's IfcSurfaceWeightsPositive where-rule crashes (list - int TypeError) on EVERY rational B-spline surface — including files authored by ifcopenshell's own API — so express-rule runs on rational- surface-heavy files report upstream-validator noise for both writers; zero writer-attributable issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…und-trip regression) The native NGEOM->IFC emitter wraps every solid in IfcBuildingElementProxy, so beams and plates read back as generic shapes — a round-trip regression vs the Python writer's IfcBeam/IfcPlate (+ axis/profile/material) parametric re-import. The xml->ifc converter leg now requires ADA_CAD_NATIVE_NGEOM_EXPORT_IFC=true (geometry-only handoff use-cases); the typed Python/streaming writer is the default again. The STEP leg stays native — STEP products carry name-only semantics either way, so nothing is lost there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…e streaming writer The proxy-only native ifc leg was fast (6.6 s hull) but untyped; the typed Python writer was slow (34.4 s — ~ms/face per-entity ifcopenshell emit for the thickened curved shells). This gets both: the memory-bounded streaming IFC writer keeps hand-authoring the TYPED product layer (IfcPlate + placement + material/style, beams via the parametric preamble), while curved and spline-boundary plates get their heavy B-rep body graph from adacpp's new ngeom_to_ifc_body_spf — a C++ SPF fragment emitter that numbers entities from the writer's id counter and returns the body item id. - stream_assembly_to_ifc routes PlateCurved + spline-boundary flat plates into the streamed-text path when the fragment binding is importable; per-object Python B-rep-emitter fallback, and a failed fragment or fallback NEVER produces a partial body (fragment text is appended only after success; the fallback syncs the id counter in a finally so a mid-emit raise cannot lead to orphaned-entity id reuse by later plates). - The xml->ifc converter leg defaults to this typed streaming path; ADA_CAD_NATIVE_NGEOM_EXPORT_IFC=true remains a geometry-only opt-in (proxy products). Hull (5,470 thickened curved shells): xml->ifc 34.4 s -> ~6 s write (15.7 s incl. the 10 s XML import), 89.6 MB, ALL products typed IfcPlate, zero proxies. Round-trip parity with the Python writer verified: flat plates -> ada.Plate, thick curved shells -> geometry-preserving shapes (same as the Python writer). Suites green both modes (fragment present / absent), lint gate passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
…-derive) Genie-XML parity kept the old re-derive path (load the source + export ifc/xml/step with parity's OWN Python writers + reload each) long after FEM sources moved to produced-files measurement — and once curved shells thicken by default, every re-derived leg built thick solids and the cells doubled-to-tripled (hull 109 s and climbing; Utror cells 2x), dominating the sweep. New parity_gxml_from_produced_files: compare per-object COUNTS (the historical gxml invariant — it caught an ifc leg silently dropping 9 of 5470 plates, which a bbox gate can miss) over the blobs the audit already produced with the production C++/typed converters. Every format has a cheap counter: produced xml via an ElementTree structure scan (faces per flat_plate/curved_shell, inline polygons, beams — the SAT blob is never touched), ifc via an SPF line scan of typed + proxy products, step via the native C++ stream index (_count_step_product_instances). Mesh formats carry no product granularity and are recorded as skipped; a missing native counter is skipped, never guessed. Zero loading, zero export, zero tessellation. Hull: 3.1 s vs 108.9 s (the last clean run) — ~35x — with counts intact (5470=5470=5470). Worker routes .xml sources through the produced-files fetch + the new comparator; other CAD sources keep their existing paths. Regression test: consistent counts on a real 3-object model through the production writers, and a doctored ifc with one product removed must flag ifc=2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
The first sweep on the produced-files gxml parity flagged 3 models with ifc > xml/step by exactly their mass count: point/ballast masses emit as IfcBuildingElementProxy in IFC, but the xml face-count and STEP solid-count structurally cannot carry them, so counting proxies flagged every mass-carrying model as an ifc-leg surplus (e.g. 1021 vs 1006 = 15 ballast masses). The shared cross-format basis is the structural set: count IfcPlate/IfcBeam/IfcMember/ IfcPipeSegment only — for a Genie-XML source the writer emits every structural object typed (including thick curved shells), so nothing structural hides in a proxy. Verified on the produced blob of the worst offender: typed count 1006 == xml == step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
The xml->obj/stl converter legs ran Python tessellation (Part.to_trimesh_scene + trimesh export) — 136.8 s each on a 5,470-object hull — and, worse, never set the stream-tess pipeline env (only the glb branch did), so they silently emitted only 84,945 of 2,246,588 triangles: OCC tessellation drops/coarsens the hull's spline plates. The native route fixes coverage AND speed. adacpp gained stream_ngeom_to_glb / stream_ngeom_to_mesh (record front-end shared with the step/ifc emitters; back half = the existing multi-threaded tessellation cores + merge-by-colour GLB/meshopt and STL/welded-OBJ writers; byte-deterministic via a commit turnstile; GEOMHEALTH stats preserved). adapy side: - ada.cadit.ngeom.export: native_to_glb / native_to_mesh + native_mesh_writers_available; a skipped solid or any dropped face raises NativeExportUnsupported -> wholesale python fallback. Density knobs come from ada.cad.registry stream_tess_defaults()/model_scale() — with the binding defaults the hull tessellated +7.6% tris; sourcing the shared knobs makes triangle counts match the python path EXACTLY (2,246,588 across glb/obj/stl, identical bbox). - Converter: _native_ngeom_mesh_route wired into the glb branch and the ada->trimesh obj/stl leg; default ON behind Config().cad_native_ngeom_export; .xml sources only; honors the engine choice (only libtess2/cdt route natively); models with FEM elements and zero-object models fall back (seeded empty-scene outputs preserved and verified). FEM->glb/obj/stl deliberately untouched: those render the FEM mesh itself, not concept objects — records routing would change WHAT is rendered, not just how. Hull benchmark (one run each): xml->glb 42.5 -> 17.0 s (14.6 MB meshopt vs 76.7 raw); xml->obj 136.8 -> 17.2 s; xml->stl 136.8 -> 17.1 s. 5470/5470 solids, 0 dropped faces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PTPCx8QJ8uDQB5RJViVct1
conda-forge ada-cpp 0.18.0 and adacpp-wasm-base:0.18.0 carry the AP242 mapped-instancing + closed-B-spline fixes and the NGEOM record-stream emitters (stream_ngeom_to_step/ifc/glb/mesh + ngeom_to_ifc_body_spf) the native Genie-XML export legs call. Bumps all four pins (two pixi features, Dockerfile.viewer ARG, ADACPP_DEFAULT_IMAGE) + lock. With track selection now discoverable from the conda binding, the cpp serializer advertises the discovered neutral tracks instead of the pinned `native` fallback — the routing test pinned that env-dependent value against its own docstring; it now asserts shape like the python serializer row. Co-Authored-By: Claude Fable 5 <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.
Audit-driven geometry fixes across the FEM→CAD conversion and cross-format parity, plus a bounded-memory FEM→NGEOM streaming export path. Theme: native-first, no geometry left behind — analytic representation over tessellated approximation, and drops made visible instead of silent. (All model names below are generic — no client identifiers.)
1. SAT reader + B-spline perf
get_face_boundread only a face's first loop, so when a degenerateholeloop (a single zero-length, curve-less coedge) was ordered ahead of the realperiphery, the wire came back empty and the whole plate failed to build (build_advanced_face: wire build failed), silently dropping valid plates from Genie-XML → STEP/IFC. Now walks the loop chain and takes the outer boundary. Regression test + minimal 28-record.satfixture._bspline_pointswas a pure-Python per-point de Boor loop (a read hot path, ~4.7s of a single large import). Vectorised across all sample points; byte-for-byte-equivalent output (equivalence test + hand-computed Bézier/rational goldens).2. FEM→NGEOM streaming export (STEP + IFC + XML), bounded memory
create_objects_from_fem), which OOM'd large models. Now streams the mesh-fused object pass (iter_objects_from_fem), one transient object at a time. Also fixed the Genie-XML streamer (didn't fuse beams/plates atmerge_strategy=None) and the IFC streamer (dropped a beam-only part's beams).parent_path, so the STEPNEXT_ASSEMBLY_USAGE_OCCURRENCEtree carries the real hierarchy (round-trip test).Sphere/ConeCSG primitives (0 faces). New kernel-freeada.geom.primitive_brepemits them as analytic B-rep (a sphere is ONESPHERICAL_SURFACEface, not a triangle soup), with an adacpp-native fallback; faceted stays only for genuinely non-analytic swept solids.3. Analytic curved-shell faces in FEM→Genie-XML
<flat_plate>polygons because the emitter couldn't express an analytic shell — while STEP/IFC already default to the analyticcylinderstrategy. FEM→XML now authors analytic faces as Genie<curved_shell>elements backed by an embedded ACIS body (cylinders re-expressed as degree-1 B-spline surfaces with a UV pcurve per boundary edge — without the pcurves a read-back face tessellates to a degenerate sliver). Planar patches stay compact<flat_plate>polygons; nothing is dropped silently. FEM→XML now defaults to the analytic strategy, matching STEP/IFC.get_all_parts_in_assembly(include_self=True)appended a non-root part that the tree-flatten already contained, so every FEM consumer processed that part's mesh twice (duplicate faces / concept names). Guarded soselfis only added when the walk didn't already include it.4. Dropped-face accountability ("no geometry left behind")
FaceBasedSurfaceModel/ShellBasedSurfaceModelface sets and whole-geometry roots whose top-level type wasn't mappable — so a model could reportface_stats={}and round to "100% coverage" on zero counted faces (a green check hiding a third of a surface). All face-set paths now route through one counter, and a new root-geometry drop counter feeds the audit'sdrop_reasons. Drops are now visible, never silent.5. Native analytic handling — prevent the OCC fallback
CONICAL_SURFACE.semi_angleraw, assuming radians, but a file may assignDEGREEas its plane-angle unit — so a1.5(meaning 1.5°) was built as 1.5 rad ≈ 86°, a near-flat cone that libtess2 meshes to 0 triangles and drops.semi_angleand numeric conic trims. On a degree-context assembly, cone faces went 234/270 → 270/270 and total face coverage 99.55% → 100%; radian-context models are byte-for-byte unchanged. No kernel change required — native+libtess2 already outperformed the OCC path on these faces.6. Cross-format parity redesign — validate what actually ships
merge_strategy=None(one plate per element) and compared entity-count equality. Two problems: it validated the unmerged model that production never ships (production ships the analyticcylindermodel), and it wrote ~1 GB of temp files per large model — which stalled badly on constrained storage.adausage keeps a fallback that derives the production outputs (not the retiredNonepath) so local and audit agree.Hygiene
chore:commit scrubs generic-only model names and doc paths from comments across the repo (comments only, no behaviour change).Verification
Deferred (intentionally out of scope)
to_stpdefault writer is not flipped to streaming (separate follow-up, gated on full lossless coverage).GeometricCurveSetloose-wireframe root still drops (now counted, not silent) — native line rendering would need kernel emit support.🤖 Generated with Claude Code