diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 4441b4dd8..6271cb72a 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -314,26 +314,29 @@ jobs: # Docs image is a Sphinx static site (deploy/Dockerfile.docs) # that also bundles the paradoc-backed FEA verification report. - # Rebuild on docs/**, the report source tree, pixi env files, - # the Dockerfile, or the workflow itself. Fast = only the doc - # sources changed (re-run fea-doc + sphinx on the env base); - # env/Dockerfile/workflow changes need the full build. + # It is by far the slowest image to build, so it is OPT-IN: it + # builds only when the head commit message carries a ``[docs]`` + # marker, regardless of what changed. Viewer/worker builds are + # unaffected. Works for push and workflow_dispatch (GITHUB_SHA is + # the branch tip either way; fetch-depth:0 above makes the message + # available). docs=false docs_full=false - if match_scope "$DOCS_CHANGED" '^docs/' || \ - match_scope "$DOCS_CHANGED" '^src/ada/' || \ - match_scope "$DOCS_CHANGED" '^pixi\.(toml|lock)$' || \ - match_scope "$DOCS_CHANGED" '^pyproject\.toml$' || \ - match_scope "$DOCS_CHANGED" '^verification/' || \ - match_scope "$DOCS_CHANGED" '^deploy/Dockerfile\.docs(-fast)?$' || \ - match_scope "$DOCS_CHANGED" '^\.forgejo/workflows/build\.yaml$'; then + COMMIT_MSG=$(git log -1 --format=%B "$GITHUB_SHA" 2>/dev/null || echo "") + if printf '%s' "$COMMIT_MSG" | grep -qiF '[docs]'; then docs=true - fi - if match_scope "$DOCS_CHANGED" '^pixi\.(toml|lock)$' || \ - match_scope "$DOCS_CHANGED" '^pyproject\.toml$' || \ - match_scope "$DOCS_CHANGED" '^deploy/Dockerfile\.docs$' || \ - match_scope "$DOCS_CHANGED" '^\.forgejo/workflows/build\.yaml$'; then - docs_full=true + # Fast build (re-run fea-doc + sphinx on the cached env base) + # unless the pixi env / Dockerfile / workflow changed — those + # need a full rebuild off a fresh env base. + if match_scope "$DOCS_CHANGED" '^pixi\.(toml|lock)$' || \ + match_scope "$DOCS_CHANGED" '^pyproject\.toml$' || \ + match_scope "$DOCS_CHANGED" '^deploy/Dockerfile\.docs$' || \ + match_scope "$DOCS_CHANGED" '^\.forgejo/workflows/build\.yaml$'; then + docs_full=true + fi + echo "[docs] present in the head commit message — building docs (full=$docs_full)." + else + echo "docs build is opt-in — add [docs] to the head commit message to build it. Skipping." fi { diff --git a/.gitattributes b/.gitattributes index 8f61a8e77..16d52b221 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # SCM syntax highlighting pixi.lock linguist-language=YAML linguist-generated=true +files/ifc_files/beam-standard-case-gz.ifc binary diff --git a/.github/workflows/ci-pages.yml b/.github/workflows/ci-pages.yml index 00e7a96f4..0694c5aad 100644 --- a/.github/workflows/ci-pages.yml +++ b/.github/workflows/ci-pages.yml @@ -50,7 +50,7 @@ jobs: with: fetch-depth: 0 # otherwise, you will fail to push refs to dest repo - - uses: prefix-dev/setup-pixi@v0.9.6 # https://github.com/prefix-dev/setup-pixi + - uses: prefix-dev/setup-pixi@v0.10.0 # https://github.com/prefix-dev/setup-pixi with: pixi-version: v0.68.0 environments: docs diff --git a/.github/workflows/main-profiling.yml b/.github/workflows/main-profiling.yml index 5e964e18f..973ac7ebe 100644 --- a/.github/workflows/main-profiling.yml +++ b/.github/workflows/main-profiling.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: Setup Pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: profile diff --git a/.github/workflows/pr-profiling.yml b/.github/workflows/pr-profiling.yml index 627671883..f94c229df 100644 --- a/.github/workflows/pr-profiling.yml +++ b/.github/workflows/pr-profiling.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: Setup Pixi - uses: prefix-dev/setup-pixi@v0.9.6 + uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: profile diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index a0e5f6997..d604d5ef2 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -19,7 +19,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 # https://github.com/prefix-dev/setup-pixi + - uses: prefix-dev/setup-pixi@v0.10.0 # https://github.com/prefix-dev/setup-pixi with: pixi-version: v0.68.0 cache: true @@ -41,7 +41,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 + - uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6032b0b67..2f58c0995 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 # https://github.com/prefix-dev/setup-pixi + - uses: prefix-dev/setup-pixi@v0.10.0 # https://github.com/prefix-dev/setup-pixi with: pixi-version: v0.68.0 environments: tests @@ -39,7 +39,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 + - uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: >- @@ -64,7 +64,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 + - uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: >- @@ -85,7 +85,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 + - uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: >- @@ -122,7 +122,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: prefix-dev/setup-pixi@v0.9.6 + - uses: prefix-dev/setup-pixi@v0.10.0 with: pixi-version: v0.68.0 environments: >- diff --git a/deploy/Dockerfile.viewer b/deploy/Dockerfile.viewer index 0478d8714..fdc26df7f 100644 --- a/deploy/Dockerfile.viewer +++ b/deploy/Dockerfile.viewer @@ -47,10 +47,11 @@ RUN npm run build:serve COPY --from=adacpp-wheel /out/ /build/dist/wheels/ COPY --from=adapy-wheel /out/ /build/dist/wheels/ -# The no-pyodide STEP->GLB embind module (adacpp_step_glb.js + .wasm) ships in the same adacpp base -# image under /out/wasm/, so the copy above lands it at dist/wheels/wasm/. Promote it to /wasm/ — where -# the in-browser "Load STEP" converter (LocalStepLoader -> stepConverter.worker) fetches it. Guarded so -# the build still works against older base images that predate the module. +# The no-pyodide embind modules (adacpp_step_glb / adacpp_ifc_glb / adacpp_brep_writer / adacpp_glb_diff +# .js + .wasm) ship in the same adacpp base image under /out/wasm/, so the copy above lands them at +# dist/wheels/wasm/. Promote the whole dir to /wasm/ — where the in-browser converters (cadGlbConverter +# / brepWriterConverter workers) and the diff utility fetch them. Guarded + globbed so the build works +# against older base images that predate any given module. RUN mkdir -p /build/dist/wasm && \ if [ -d /build/dist/wheels/wasm ]; then \ mv /build/dist/wheels/wasm/* /build/dist/wasm/ && rmdir /build/dist/wheels/wasm; \ diff --git a/deploy/Dockerfile.worker b/deploy/Dockerfile.worker index c9b548dde..d620162a3 100644 --- a/deploy/Dockerfile.worker +++ b/deploy/Dockerfile.worker @@ -6,6 +6,15 @@ # (the build runner has no egress to Ubuntu mirrors). The conda env # itself dominates final image size, so a multi-stage trim wouldn't # meaningfully shrink things. +# +# Rebuild marker (bump to re-pull the ADACPP_BRANCH overlay fresh): +# 2026-07-13e — adacpp feat/lazy-shape-store @ 3e18e9f: curved-surface fidelity fixes. Respects the +# curved trim on near-rectangular B-spline faces (bevels no longer render as straight-edged +# rectangles) and adds scale-invariant angular refinement so curved interiors aren't faceted +# (cylinders/holes were tessellating as prisms/squares; bevels were internally inconsistent). Also +# opt-in per-face clickable regions (face_ranges_node via ADA_STREAM_TESS_FACE_REGIONS). Still carries +# the linear-extrusion face fix, dropped-face health counter, IFC linear placement + opening voids, +# the bogus-SI-prefix guard (empty tanks), GLB-spill RAM buffering, tri-count logging, parent malloc_trim. ARG PIXI_VERSION=0.68.0 # CI passes --build-arg IMAGE_TAG=sha-XXXXXXX so the worker can publish @@ -19,9 +28,11 @@ ARG IMAGE_TAG=dev # CI stages the branch source into ./adacpp-src (clone) or an empty dir, and passes the branch # name so the builder stage below knows whether to build. Built with adacpp's own pinned env # (occt ==7.9.3), which matches what the released ada-cpp links → ABI-compatible .so. -# The feat/ngeom-alignment-sweep overlay HEAD carries: alignment fixed-reference swept solid -# (SweepN), the step_parity single-parse fan-out (STEP parity), and incremental meshopt in the -# native GLB writer (halves peak RSS on large merged GLBs — fixes the STEP→GLB OOM). +# The feat/lazy-shape-store overlay HEAD carries: the z=0 face-set plane-fit (IfcFacetedBrep / +# Triangulated- + PolygonalFaceSet no longer render as a flat sheet), parallel native IFC→GLB, +# a B-spline-weighted LPT cost estimate + per-solid audit instrumentation (spearman_est_ms/tris), +# the NAUO-gate fix for the crane STEP→GLB regression, and a moderate 4 MB malloc mmap/trim that +# trims streaming-tessellation peak RSS. ARG ADACPP_BRANCH="" # ---- optional adacpp-branch overlay (gated by ADACPP_BRANCH) ----------------------- diff --git a/deploy/helm/adapy-viewer/templates/_helpers.tpl b/deploy/helm/adapy-viewer/templates/_helpers.tpl index 02fc4ee50..208034e05 100644 --- a/deploy/helm/adapy-viewer/templates/_helpers.tpl +++ b/deploy/helm/adapy-viewer/templates/_helpers.tpl @@ -202,6 +202,33 @@ spec: {{- with $w.resources }} resources: {{- toYaml . | nindent 12 }} {{- end }} + # Liveness: the worker touches /tmp/worker-alive on every JetStream pull-loop iteration + # (<=5s when idle) and on conversion progress (~2s). If the pull loop stalls — the durable + # consumer wedges after a NATS restart, leaving the pod "Running" but no longer fetching + # (num_waiting=0) — the file goes stale and k8s restarts the pod, instead of it sitting + # silently broken while jobs pile up unconsumed. + # + # This heartbeat probe ONLY works on pools running the adapy worker image, which writes the + # file. Capability pools that run a foreign image (abaqus, weld-gen) never write it, so the + # probe would fail every time and SIGKILL-crashloop them. Hence it is OPT-IN: emitted only + # when the pool sets `heartbeatLiveness: true` (the adapy worker default) or supplies its own + # `livenessProbe:`. Pools that do neither get no liveness probe. + {{- if or $w.livenessProbe $w.heartbeatLiveness }} + livenessProbe: + {{- with $w.livenessProbe }} + {{- toYaml . | nindent 12 }} + {{- else }} + exec: + command: + - sh + - -c + - 'test $(( $(date +%s) - $(stat -c %Y /tmp/worker-alive 2>/dev/null || echo 0) )) -lt 120' + initialDelaySeconds: 120 + periodSeconds: 30 + failureThreshold: 4 + timeoutSeconds: 5 + {{- end }} + {{- end }} {{- with $w.securityContext }} securityContext: {{- toYaml . | nindent 12 }} {{- end }} diff --git a/deploy/helm/adapy-viewer/values.yaml b/deploy/helm/adapy-viewer/values.yaml index ec5c39c90..30fb64a6b 100644 --- a/deploy/helm/adapy-viewer/values.yaml +++ b/deploy/helm/adapy-viewer/values.yaml @@ -49,6 +49,11 @@ strategy: worker: enabled: true replicaCount: 1 + # This pool runs the adapy worker image, which writes /tmp/worker-alive on + # every pull-loop iteration, so the heartbeat livenessProbe applies (restarts + # a wedged pull loop). Capability pools on foreign images must leave this + # unset — see the livenessProbe note in _helpers.tpl. + heartbeatLiveness: true image: repository: ghcr.io/krande/adapy-viewer-worker tag: "" diff --git a/files/ifc_files/bath-csg-solid.ifc b/files/ifc_files/bath-csg-solid.ifc new file mode 100644 index 000000000..d66a47edc --- /dev/null +++ b/files/ifc_files/bath-csg-solid.ifc @@ -0,0 +1,70 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'bath-csg-solid.ifc', +/* time_stamp */ '2016-02-04T08:47:55', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* general entities required for all IFC data sets, defining the context for the exchange */ +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#2,$,$); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +/* defines the default building (as required as the minimum spatial element) */ +#50= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',#56,'IfcBuilding',$,$,$,$,$,$,$,$,$); +#51= IFCPERSONANDORGANIZATION(#52,#53,$); +#52= IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#53= IFCORGANIZATION($,'redacted',$,$,$); +#54= IFCAPPLICATION(#55,'redacted','redacted','redacted'); +#55= IFCORGANIZATION($,'redacted',$,$,$); +#56= IFCOWNERHISTORY(#51,#54,$,.ADDED.,1454575675,$,$,1454575675); +#57= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',#56,'Building','Building Container for Elements',(#225),#50); +#58= IFCAXIS2PLACEMENT3D(#2,$,$); +#100= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',#56,'IfcProject',$,$,$,$,(#1),#101); +#101= IFCUNITASSIGNMENT((#102,#103,#104)); +#102= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#103= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#104= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#105= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',#56,'Project Container','Project Container for Buildings',#100,(#50)); +#200= IFCBLOCK(#201,2000.0,800.0,800.0); +#201= IFCAXIS2PLACEMENT3D(#2,$,$); +#202= IFCROUNDEDRECTANGLEPROFILEDEF(.AREA.,'VoidProfile',$,1800.0,600.0,200.0); +#203= IFCAXIS2PLACEMENT3D(#204,$,$); +#204= IFCCARTESIANPOINT((1000.0,400.0,100.0)); +#205= IFCDIRECTION((0.0,0.0,1.0)); +#206= IFCEXTRUDEDAREASOLID(#202,#203,#205,700.0); +#207= IFCBOOLEANRESULT(.DIFFERENCE.,#200,#206); +#208= IFCCSGSOLID(#207); +#209= IFCREPRESENTATIONMAP(#210,#211); +#210= IFCAXIS2PLACEMENT3D(#2,$,$); +#211= IFCSHAPEREPRESENTATION(#5,'Body','SolidModel',(#208)); +#212= IFCMATERIAL('Ceramic',$,$); +#213= IFCRELASSOCIATESMATERIAL('0Pkhszwjv1qRMYyCFg9fjB',#56,'MatAssoc','Material Associates',(#214),#212); +#214= IFCSANITARYTERMINALTYPE('1HarmwaPv3OeJSXpaoPKpg',#56,'Bath',$,$,$,(#209),$,$,.BATH.); +#215= IFCRELDEFINESBYTYPE('1lO$X3e3j9lfVMhNy4MzKB',#56,$,$,(#225),#214); +#216= IFCRELDECLARES('1acQrDhur9399Xqs2jQs4t',#56,$,$,#100,(#214)); +#217= IFCDIRECTION((1.0,0.0,0.0)); +#218= IFCDIRECTION((0.0,1.0,0.0)); +#219= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#220= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#217,#218,#219,1.0,#221); +#221= IFCDIRECTION((0.0,0.0,1.0)); +#222= IFCMAPPEDITEM(#209,#220); +#223= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#222)); +#224= IFCPRODUCTDEFINITIONSHAPE($,$,(#223)); +#225= IFCSANITARYTERMINAL('3$$o7C03j0KQeLnoj018fc',#56,$,$,$,#227,#224,$,$); +#226= IFCAXIS2PLACEMENT3D(#2,$,$); +#227= IFCLOCALPLACEMENT($,#226); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/beam-extruded-solid.ifc b/files/ifc_files/beam-extruded-solid.ifc new file mode 100644 index 000000000..992e131c2 --- /dev/null +++ b/files/ifc_files/beam-extruded-solid.ifc @@ -0,0 +1,50 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'beam-extruded-solid.ifc', +/* time_stamp */ '2016-02-04T08:11:04', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* general entities required for all IFC data sets, defining the context for the exchange */ +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#2,$,$); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +/* defines the default building (as required as the minimum spatial element) */ +#50= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',$,'IfcBuilding',$,$,$,$,$,$,$,$,$); +#51= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',$,'Building','Building Container for Elements',(#211),#50); +#52= IFCAXIS2PLACEMENT3D(#2,$,$); +#100= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',$,'IfcProject',$,$,$,$,(#1),#101); +#101= IFCUNITASSIGNMENT((#102,#103,#104)); +#102= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#103= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#104= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#105= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',$,'Project Container','Project Container for Buildings',#100,(#50)); +#200= IFCCARTESIANPOINTLIST2D(((2.8,-79.5),(2.8,79.5),(6.31471899999999,87.985281),(14.8,91.5),(50.0,91.5),(50.0,100.0),(-50.0,100.0),(-50.0,91.5),(-14.8,91.5),(-6.31471899999997,87.985281),(-2.79999999999999,79.5),(-2.8,-79.5),(-6.31471899999999,-87.985281),(-14.8,-91.5),(-50.0,-91.5),(-50.0,-100.0),(50.0,-100.0),(50.0,-91.5),(14.8,-91.5),(6.31471899999997,-87.985281)),$); +#201= IFCINDEXEDPOLYCURVE(#200,(IFCLINEINDEX((1,2)),IFCARCINDEX((2,3,4)),IFCLINEINDEX((4,5,6,7,8,9)),IFCARCINDEX((9,10,11)),IFCLINEINDEX((11,12)),IFCARCINDEX((12,13,14)),IFCLINEINDEX((14,15,16,17,18,19)),IFCARCINDEX((19,20,1))),.F.); +#202= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'IPE200',#201); +#203= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#204= IFCDIRECTION((0.0,1.0,0.0)); +#205= IFCDIRECTION((1.0,0.0,0.0)); +#206= IFCAXIS2PLACEMENT3D(#203,#204,#205); +#207= IFCDIRECTION((0.0,0.0,1.0)); +#208= IFCEXTRUDEDAREASOLID(#202,#206,#207,1000.0); +#209= IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#208)); +#210= IFCPRODUCTDEFINITIONSHAPE($,$,(#209)); +#211= IFCBEAM('0EF5_zZRv0pQPddeofU3KT',$,'ExampleBeamName','ExampleBeamDescription',$,#213,#210,'Tag',$); +#212= IFCAXIS2PLACEMENT3D(#2,$,$); +#213= IFCLOCALPLACEMENT($,#212); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/beam-standard-case-gz.ifc b/files/ifc_files/beam-standard-case-gz.ifc new file mode 100644 index 000000000..490f86d57 Binary files /dev/null and b/files/ifc_files/beam-standard-case-gz.ifc differ diff --git a/files/ifc_files/bs_samples/column-straight-rectangle-tessellation.ifc b/files/ifc_files/bs_samples/column-straight-rectangle-tessellation.ifc new file mode 100644 index 000000000..7d3b027ea --- /dev/null +++ b/files/ifc_files/bs_samples/column-straight-rectangle-tessellation.ifc @@ -0,0 +1,64 @@ +ISO-10303-21; +HEADER; + +FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1'); +FILE_NAME('column-straight-rectangle-tessellation.ifc','2014-07-08T19:20:14',('redacted'),(''),'redacted','redacted - redacted - 3.14159',''); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; + +DATA; + +/* set the default units - and the units used for geometric representations */ +/* here imperial units (inch) are used */ +/* ---------------------------------------------------------------------------------------------- */ +#12= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#13= IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(0.0254),#12); +#14= IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0); +#15= IFCCONVERSIONBASEDUNIT(#14,.LENGTHUNIT.,'inch',#13); +#36= IFCUNITASSIGNMENT((#15)); + +/* set the context of the IFC4 exchange file */ +/* name, units and geometric representation context */ +/* note: IfcOwnerHistory is not in scope of the IFC4 reference view */ +/* ---------------------------------------------------------------------------------------------- */ +#37= IFCPROJECT('0CxDbxzA1B4eLeOw9eIjQx',$,'Project',$,$,$,$,(#40),#36); + +/* set the representation context for 3D body, and 2D axis representation */ +/* no north direction and no geo-spatial coordinates are provided */ +/* ---------------------------------------------------------------------------------------------- */ +#38= IFCCARTESIANPOINT((0.,0.,0.)); +#39= IFCAXIS2PLACEMENT3D(#38,$,$); +#40= IFCGEOMETRICREPRESENTATIONCONTEXT('3D','Model',3,1.0E-05,#39,$); +#41= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#40,$,.MODEL_VIEW.,$); + +/* defines the default site (as required as the minimum spatial element) */ +/* ---------------------------------------------------------------------------------------------- */ +#44= IFCSITE('0M0akNk9f3hOH9u2awmtre',$,'Site #1',$,$,#67,$,$,.ELEMENT.,$,$,$,$,$); +#45= IFCRELAGGREGATES('2y_abP04108BvIhy2WfsHm',$,$,$,#37,(#44)); +#67= IFCLOCALPLACEMENT($,#69); +#68= IFCCARTESIANPOINT((0.,0.,0.)); +#69= IFCAXIS2PLACEMENT3D(#68,$,$); + +/* defines the column with rectangular cross section as a model element */ +/* using tessellated geometry */ +/* ---------------------------------------------------------------------------------------------- */ +#71= IFCCOLUMN('2WUGYBphrFv8aLIFJCmiIk',$,'Column #1',$,$,#121,#111,$,.COLUMN.); +#111= IFCPRODUCTDEFINITIONSHAPE($,$,(#154)); +#116= IFCRELCONTAINEDINSPATIALSTRUCTURE('0F1oUoJsr5ZwxKAxiD1Vep',$,$,$,(#71),#44); +#119= IFCDIRECTION((0.,0.,1.)); +#120= IFCDIRECTION((1.,0.,0.)); +#121= IFCLOCALPLACEMENT(#67,#126); +#125= IFCCARTESIANPOINT((432.,288.,48.)); +#126= IFCAXIS2PLACEMENT3D(#125,#119,#120); +#154= IFCSHAPEREPRESENTATION(#41,'Body','Tessellation',(#288)); + +/* the triangulated tessellation also includes the normals per face and vertex */ +/* in this case, the vertex list and the normal list are corresponding lists, */ +/* hence no separate normal index has to be provided at IfcTriangulatedFaceSet */ +/* it generated longer lists, but is in line with many graphic APIs using a single buffer list */ +/* ---------------------------------------------------------------------------------------------- */ +#287= IFCCARTESIANPOINTLIST3D(((-4.,4.,0.),(-4.,-4.,0.),(4.,-4.,0.),(4.,4.,0.),(-4.,4.,120.),(-4.,-4.,120.),(4.,-4.,120.),(4.,4.,120.),(-4.,4.,0.),(-4.,-4.,0.),(-4.,-4.,120.),(-4.,4.,120.),(-4.,-4.,0.),(4.,-4.,0.),(4.,-4.,120.),(-4.,-4.,120.),(4.,-4.,0.),(4.,4.,0.),(4.,4.,120.),(4.,-4.,120.),(4.,4.,0.),(-4.,4.,0.),(-4.,4.,120.),(4.,4.,120.)),$); +#288= IFCTRIANGULATEDFACESET(#287,((0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(-1.,0.,0.),(-1.,0.,0.),(-1.,0.,0.),(-1.,0.,0.),(0.,-1.,0.),(0.,-1.,0.),(0.,-1.,0.),(0.,-1.,0.),(1.,0.,0.),(1.,0.,0.),(1.,0.,0.),(1.,0.,0.),(0.,1.,0.),(0.,1.,0.),(0.,1.,0.),(0.,1.,0.)),.T.,((1,3,2),(1,4,3),(5,6,7),(5,7,8),(9,10,11),(9,11,12),(13,14,15),(13,15,16),(17,18,19),(17,19,20),(21,22,23),(21,23,24)),$); +ENDSEC; + +END-ISO-10303-21; diff --git a/files/ifc_files/bs_samples/curve-parameters-in-degrees.ifc b/files/ifc_files/bs_samples/curve-parameters-in-degrees.ifc new file mode 100644 index 000000000..65c8d7649 --- /dev/null +++ b/files/ifc_files/bs_samples/curve-parameters-in-degrees.ifc @@ -0,0 +1,155 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'curve-parameters-in-degrees.ifc', +/* time_stamp */ '2017-06-28T23:09:53', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +#10= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#11= IFCAXIS2PLACEMENT3D(#10,$,$); +#12= IFCLOCALPLACEMENT($,#11); +/* defines the default building (as required as the minimum spatial element) */ +/* These profile curves are intentionally expressed in a more complicated manner than necessary to test parameterization */ +#13= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',$,'IfcBuilding',$,$,#12,$,$,$,$,$,#18); +#14= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',$,'Building','Building Container for Elements',(#77,#131,#180),#13); +#15= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#16= IFCAXIS2PLACEMENT3D(#15,$,$); +#18= IFCPOSTALADDRESS($,$,$,$,('Unknown'),$,'Unknown',$,'Unknown','Unknown'); +/* general entities required for all IFC data sets, defining the context for the exchange */ +#20= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',$,'IfcProject',$,$,$,$,(#31),#21); +#21= IFCUNITASSIGNMENT((#22,#23,#24,#27,#29)); +#22= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#23= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#24= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#25= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#26= IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.0174532925199433),#25); +#27= IFCCONVERSIONBASEDUNIT(#28,.PLANEANGLEUNIT.,'DEGREE',#26); +#28= IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#29= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#30= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',$,'Project Container','Project Container for Buildings',#20,(#13)); +#31= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#33,#34); +#32= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#33= IFCAXIS2PLACEMENT3D(#32,$,$); +#34= IFCDIRECTION((0.0,1.0)); +#35= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#31,$,.MODEL_VIEW.,$); +/* Example data for CurveParametersDegrees */ +#50= IFCMATERIAL('Steel',$,$); +#52= IFCCARTESIANPOINT((-1000.0,1000.0)); +#53= IFCDIRECTION((0.70710678,-0.70710678)); +#54= IFCVECTOR(#53,1414.2135623731); +#55= IFCLINE(#52,#54); +#56= IFCTRIMMEDCURVE(#55,(IFCPARAMETERVALUE(0.292893218813453)),(IFCPARAMETERVALUE(1.70710678118655)),.T.,.PARAMETER.); +#57= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#56); +#58= IFCCARTESIANPOINT((0.0,0.0)); +#59= IFCAXIS2PLACEMENT2D(#58,$); +#60= IFCCIRCLE(#59,1000.0); +#61= IFCTRIMMEDCURVE(#60,(IFCPARAMETERVALUE(315.0)),(IFCPARAMETERVALUE(135.0)),.T.,.PARAMETER.); +#62= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#61); +#63= IFCCOMPOSITECURVE((#57,#62),.U.); +#64= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'SemiCircle',#63); +#65= IFCMATERIALPROFILE('SemiCircle',$,#50,#64,$,$); +#67= IFCMATERIALPROFILESET('SemiCircle',$,(#65),$); +#68= IFCRELASSOCIATESMATERIAL('1gdVo5TjPETPZlW8HSRupM',$,'MatAssoc','Material Associates',(#69),#67); +#69= IFCCOLUMNTYPE('24mq0gwVr7bgEMXPmo$TrF',$,'SemiCircle',$,$,$,$,$,$,.COLUMN.); +#70= IFCRELDEFINESBYTYPE('0devdSRyf3uBEQbSqWTDjo',$,'NameNotAssigned',$,(#77),#69); +#71= IFCRELDECLARES('1Cjr05W9T0fx0M3_mdVqMd',$,$,$,#20,(#69,#124,#173)); +#72= IFCMATERIALPROFILESETUSAGE(#67,5,$); +#73= IFCRELASSOCIATESMATERIAL('35z8gDFbb6gvrCOz$24tUJ',$,'MatAssoc','Material Associates',(#77),#72); +#74= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#75= IFCAXIS2PLACEMENT3D(#74,$,$); +#76= IFCLOCALPLACEMENT(#12,#75); +#77= IFCCOLUMN('0RGc8lepr7BRF_EtHrWJ45',$,'SemiCircle',$,$,#76,#85,$,$); +#78= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#79= IFCPOLYLINE((#15,#78)); +#80= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#31,$,.MODEL_VIEW.,$); +#81= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#79)); +#82= IFCEXTRUDEDAREASOLID(#64,$,#83,2000.0); +#83= IFCDIRECTION((0.0,0.0,1.0)); +#84= IFCSHAPEREPRESENTATION(#35,'Body','SweptSolid',(#82)); +#85= IFCPRODUCTDEFINITIONSHAPE($,$,(#81,#84)); +#100= IFCCARTESIANPOINT((0.0,1000.0)); +#101= IFCAXIS2PLACEMENT2D(#100,#102); +#102= IFCDIRECTION((-1.0,0.0)); +#103= IFCCIRCLE(#101,1732.05081); +#104= IFCTRIMMEDCURVE(#103,(IFCPARAMETERVALUE(60.0)),(IFCPARAMETERVALUE(120.0)),.T.,.PARAMETER.); +#105= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#104); +#106= IFCCARTESIANPOINT((-866.0254,-500.0)); +#107= IFCAXIS2PLACEMENT2D(#106,#108); +#108= IFCDIRECTION((0.0,-1.0)); +#109= IFCCIRCLE(#107,1732.05081); +#110= IFCTRIMMEDCURVE(#109,(IFCPARAMETERVALUE(90.0)),(IFCPARAMETERVALUE(150.0)),.T.,.PARAMETER.); +#111= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#110); +#112= IFCCARTESIANPOINT((866.0254,-500.0)); +#113= IFCAXIS2PLACEMENT2D(#112,#114); +#114= IFCDIRECTION((0.0,1.0)); +#115= IFCCIRCLE(#113,1732.05081); +#116= IFCTRIMMEDCURVE(#115,(IFCPARAMETERVALUE(30.0)),(IFCPARAMETERVALUE(90.0)),.T.,.PARAMETER.); +#117= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#116); +#118= IFCCOMPOSITECURVE((#105,#111,#117),.U.); +#119= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'CurviLinearTriangle',#118); +#120= IFCMATERIALPROFILE('CurviLinearTriangle',$,#50,#119,$,$); +#122= IFCMATERIALPROFILESET('CurviLinearTriangle',$,(#120),$); +#123= IFCRELASSOCIATESMATERIAL('1qSyS$HSb8TRu4PVnIUzZM',$,'MatAssoc','Material Associates',(#124),#122); +#124= IFCCOLUMNTYPE('0JgmY6eGLC9AwX_nMFf5CT',$,'CurviLinearTriangle',$,$,$,$,$,$,.COLUMN.); +#125= IFCRELDEFINESBYTYPE('1N1PpBCJLB4QMrIjidnGaj',$,'NameNotAssigned',$,(#131),#124); +#126= IFCMATERIALPROFILESETUSAGE(#122,5,$); +#127= IFCRELASSOCIATESMATERIAL('00Bah4pIPCa9jB2F34kUX_',$,'MatAssoc','Material Associates',(#131),#126); +#128= IFCCARTESIANPOINT((2500.0,0.0,0.0)); +#129= IFCAXIS2PLACEMENT3D(#128,$,$); +#130= IFCLOCALPLACEMENT(#12,#129); +#131= IFCCOLUMN('3vcm8ZmFfDwhpgzzT7EP8n',$,'CurviLinearTriangle',$,$,#130,#137,$,$); +#132= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#133= IFCPOLYLINE((#15,#132)); +#134= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#133)); +#135= IFCEXTRUDEDAREASOLID(#119,$,#83,2000.0); +#136= IFCSHAPEREPRESENTATION(#35,'Body','SweptSolid',(#135)); +#137= IFCPRODUCTDEFINITIONSHAPE($,$,(#134,#136)); +#150= IFCCARTESIANPOINT((0.0,0.0)); +#151= IFCAXIS2PLACEMENT2D(#150,$); +#152= IFCELLIPSE(#151,1000.0,500.0); +#153= IFCTRIMMEDCURVE(#152,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(45.0)),.T.,.PARAMETER.); +#154= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#153); +#155= IFCCARTESIANPOINT((0.0,0.0)); +#156= IFCDIRECTION((0.89442719,0.4472136)); +#157= IFCVECTOR(#156,1.0); +#158= IFCLINE(#155,#157); +#159= IFCTRIMMEDCURVE(#158,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(790.569415042095)),.F.,.PARAMETER.); +#160= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#159); +#161= IFCCARTESIANPOINT((0.0,0.0)); +#162= IFCDIRECTION((1.0,0.0)); +#163= IFCVECTOR(#162,1.0); +#164= IFCLINE(#161,#163); +#165= IFCTRIMMEDCURVE(#164,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(1000.0)),.T.,.PARAMETER.); +#166= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#165); +#167= IFCCOMPOSITECURVE((#154,#160,#166),.U.); +#168= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PartialEllipse',#167); +#169= IFCMATERIALPROFILE('PartialEllipse',$,#50,#168,$,$); +#171= IFCMATERIALPROFILESET('PartialEllipse',$,(#169),$); +#172= IFCRELASSOCIATESMATERIAL('2V$PMUw5f3PgVGq_LG8lb7',$,'MatAssoc','Material Associates',(#173),#171); +#173= IFCCOLUMNTYPE('32anvmJgjFPOL650_UAlfM',$,'PartialEllipse',$,$,$,$,$,$,.COLUMN.); +#174= IFCRELDEFINESBYTYPE('1YdxXK2rrC6RnMj56iAUZG',$,'NameNotAssigned',$,(#180),#173); +#175= IFCMATERIALPROFILESETUSAGE(#171,5,$); +#176= IFCRELASSOCIATESMATERIAL('1k_RZ6rAPBe92Lvj8VqnvM',$,'MatAssoc','Material Associates',(#180),#175); +#177= IFCCARTESIANPOINT((5000.0,0.0,0.0)); +#178= IFCAXIS2PLACEMENT3D(#177,$,$); +#179= IFCLOCALPLACEMENT(#12,#178); +#180= IFCCOLUMN('0gw7Zq2jn3b91J9aZCStsR',$,'PartialEllipse',$,$,#179,#186,$,$); +#181= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#182= IFCPOLYLINE((#15,#181)); +#183= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#182)); +#184= IFCEXTRUDEDAREASOLID(#168,$,#83,2000.0); +#185= IFCSHAPEREPRESENTATION(#35,'Body','SweptSolid',(#184)); +#186= IFCPRODUCTDEFINITIONSHAPE($,$,(#183,#185)); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/bs_samples/curve-parameters-in-radians.ifc b/files/ifc_files/bs_samples/curve-parameters-in-radians.ifc new file mode 100644 index 000000000..b3df78341 --- /dev/null +++ b/files/ifc_files/bs_samples/curve-parameters-in-radians.ifc @@ -0,0 +1,152 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'curve-parameters-in-radians.ifc', +/* time_stamp */ '2017-06-28T23:09:53', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +#10= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#11= IFCAXIS2PLACEMENT3D(#10,$,$); +#12= IFCLOCALPLACEMENT($,#11); +/* defines the default building (as required as the minimum spatial element) */ +/* These profile curves are intentionally expressed in a more complicated manner than necessary to test parameterization */ +#13= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',$,'IfcBuilding',$,$,#12,$,$,$,$,$,#18); +#14= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',$,'Building','Building Container for Elements',(#77,#131,#180),#13); +#15= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#16= IFCAXIS2PLACEMENT3D(#15,$,$); +#18= IFCPOSTALADDRESS($,$,$,$,('Unknown'),$,'Unknown',$,'Unknown','Unknown'); +/* general entities required for all IFC data sets, defining the context for the exchange */ +#20= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',$,'IfcProject',$,$,$,$,(#28),#21); +#21= IFCUNITASSIGNMENT((#22,#23,#24,#25,#26)); +#22= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#23= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#24= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#25= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#26= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#27= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',$,'Project Container','Project Container for Buildings',#20,(#13)); +#28= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#30,#31); +#29= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#30= IFCAXIS2PLACEMENT3D(#29,$,$); +#31= IFCDIRECTION((0.0,1.0)); +#32= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#28,$,.MODEL_VIEW.,$); +/* Example data for CurveParametersRadians */ +#50= IFCMATERIAL('Steel',$,$); +#52= IFCCARTESIANPOINT((-1000.0,1000.0)); +#53= IFCDIRECTION((0.70710678,-0.70710678)); +#54= IFCVECTOR(#53,1414.2135623731); +#55= IFCLINE(#52,#54); +#56= IFCTRIMMEDCURVE(#55,(IFCPARAMETERVALUE(0.292893218813453)),(IFCPARAMETERVALUE(1.70710678118655)),.T.,.PARAMETER.); +#57= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#56); +#58= IFCCARTESIANPOINT((0.0,0.0)); +#59= IFCAXIS2PLACEMENT2D(#58,$); +#60= IFCCIRCLE(#59,1000.0); +#61= IFCTRIMMEDCURVE(#60,(IFCPARAMETERVALUE(5.49778714378214)),(IFCPARAMETERVALUE(2.35619449019234)),.T.,.PARAMETER.); +#62= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#61); +#63= IFCCOMPOSITECURVE((#57,#62),.U.); +#64= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'SemiCircle',#63); +#65= IFCMATERIALPROFILE('SemiCircle',$,#50,#64,$,$); +#67= IFCMATERIALPROFILESET('SemiCircle',$,(#65),$); +#68= IFCRELASSOCIATESMATERIAL('1gdVo5TjPETPZlW8HSRupM',$,'MatAssoc','Material Associates',(#69),#67); +#69= IFCCOLUMNTYPE('24mq0gwVr7bgEMXPmo$TrF',$,'SemiCircle',$,$,$,$,$,$,.COLUMN.); +#70= IFCRELDEFINESBYTYPE('0devdSRyf3uBEQbSqWTDjo',$,'NameNotAssigned',$,(#77),#69); +#71= IFCRELDECLARES('1Cjr05W9T0fx0M3_mdVqMd',$,$,$,#20,(#69,#124,#173)); +#72= IFCMATERIALPROFILESETUSAGE(#67,5,$); +#73= IFCRELASSOCIATESMATERIAL('35z8gDFbb6gvrCOz$24tUJ',$,'MatAssoc','Material Associates',(#77),#72); +#74= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#75= IFCAXIS2PLACEMENT3D(#74,$,$); +#76= IFCLOCALPLACEMENT(#12,#75); +#77= IFCCOLUMN('0RGc8lepr7BRF_EtHrWJ45',$,'SemiCircle',$,$,#76,#85,$,$); +#78= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#79= IFCPOLYLINE((#15,#78)); +#80= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#28,$,.MODEL_VIEW.,$); +#81= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#79)); +#82= IFCEXTRUDEDAREASOLID(#64,$,#83,2000.0); +#83= IFCDIRECTION((0.0,0.0,1.0)); +#84= IFCSHAPEREPRESENTATION(#32,'Body','SweptSolid',(#82)); +#85= IFCPRODUCTDEFINITIONSHAPE($,$,(#81,#84)); +#100= IFCCARTESIANPOINT((0.0,1000.0)); +#101= IFCAXIS2PLACEMENT2D(#100,#102); +#102= IFCDIRECTION((-1.0,0.0)); +#103= IFCCIRCLE(#101,1732.05081); +#104= IFCTRIMMEDCURVE(#103,(IFCPARAMETERVALUE(1.0471975511966)),(IFCPARAMETERVALUE(2.0943951023932)),.T.,.PARAMETER.); +#105= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#104); +#106= IFCCARTESIANPOINT((-866.0254,-500.0)); +#107= IFCAXIS2PLACEMENT2D(#106,#108); +#108= IFCDIRECTION((0.0,-1.0)); +#109= IFCCIRCLE(#107,1732.05081); +#110= IFCTRIMMEDCURVE(#109,(IFCPARAMETERVALUE(1.5707963267949)),(IFCPARAMETERVALUE(2.61799387799149)),.T.,.PARAMETER.); +#111= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#110); +#112= IFCCARTESIANPOINT((866.0254,-500.0)); +#113= IFCAXIS2PLACEMENT2D(#112,#114); +#114= IFCDIRECTION((0.0,1.0)); +#115= IFCCIRCLE(#113,1732.05081); +#116= IFCTRIMMEDCURVE(#115,(IFCPARAMETERVALUE(0.523598775598299)),(IFCPARAMETERVALUE(1.5707963267949)),.T.,.PARAMETER.); +#117= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#116); +#118= IFCCOMPOSITECURVE((#105,#111,#117),.U.); +#119= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'CurviLinearTriangle',#118); +#120= IFCMATERIALPROFILE('CurviLinearTriangle',$,#50,#119,$,$); +#122= IFCMATERIALPROFILESET('CurviLinearTriangle',$,(#120),$); +#123= IFCRELASSOCIATESMATERIAL('1M5oofzjD3IOM43brXW6wT',$,'MatAssoc','Material Associates',(#124),#122); +#124= IFCCOLUMNTYPE('3N_qc_BjX1hvEgwfRvVcb_',$,'CurviLinearTriangle',$,$,$,$,$,$,.COLUMN.); +#125= IFCRELDEFINESBYTYPE('3tGocD1N51oOvSvHbJI_qD',$,'NameNotAssigned',$,(#131),#124); +#126= IFCMATERIALPROFILESETUSAGE(#122,5,$); +#127= IFCRELASSOCIATESMATERIAL('0gnTzVmkbE9hPsJDxOUOL3',$,'MatAssoc','Material Associates',(#131),#126); +#128= IFCCARTESIANPOINT((2500.0,0.0,0.0)); +#129= IFCAXIS2PLACEMENT3D(#128,$,$); +#130= IFCLOCALPLACEMENT(#12,#129); +#131= IFCCOLUMN('0bmIILAwj8$PLHK1jcmad0',$,'CurviLinearTriangle',$,$,#130,#137,$,$); +#132= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#133= IFCPOLYLINE((#15,#132)); +#134= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#133)); +#135= IFCEXTRUDEDAREASOLID(#119,$,#83,2000.0); +#136= IFCSHAPEREPRESENTATION(#32,'Body','SweptSolid',(#135)); +#137= IFCPRODUCTDEFINITIONSHAPE($,$,(#134,#136)); +#150= IFCCARTESIANPOINT((0.0,0.0)); +#151= IFCAXIS2PLACEMENT2D(#150,$); +#152= IFCELLIPSE(#151,1000.0,500.0); +#153= IFCTRIMMEDCURVE(#152,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(0.785398163397448)),.T.,.PARAMETER.); +#154= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#153); +#155= IFCCARTESIANPOINT((0.0,0.0)); +#156= IFCDIRECTION((0.89442719,0.4472136)); +#157= IFCVECTOR(#156,1.0); +#158= IFCLINE(#155,#157); +#159= IFCTRIMMEDCURVE(#158,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(790.569415042095)),.F.,.PARAMETER.); +#160= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#159); +#161= IFCCARTESIANPOINT((0.0,0.0)); +#162= IFCDIRECTION((1.0,0.0)); +#163= IFCVECTOR(#162,1.0); +#164= IFCLINE(#161,#163); +#165= IFCTRIMMEDCURVE(#164,(IFCPARAMETERVALUE(0.0)),(IFCPARAMETERVALUE(1000.0)),.T.,.PARAMETER.); +#166= IFCCOMPOSITECURVESEGMENT(.CONTINUOUS.,.T.,#165); +#167= IFCCOMPOSITECURVE((#154,#160,#166),.U.); +#168= IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,'PartialEllipse',#167); +#169= IFCMATERIALPROFILE('PartialEllipse',$,#50,#168,$,$); +#171= IFCMATERIALPROFILESET('PartialEllipse',$,(#169),$); +#172= IFCRELASSOCIATESMATERIAL('2OfhB1Dcz2cAdV$CDh9PHV',$,'MatAssoc','Material Associates',(#173),#171); +#173= IFCCOLUMNTYPE('0dtemVu1P2682BcO3CPWAy',$,'PartialEllipse',$,$,$,$,$,$,.COLUMN.); +#174= IFCRELDEFINESBYTYPE('0rNx6sqCH2mOt1cWOT6zSU',$,'NameNotAssigned',$,(#180),#173); +#175= IFCMATERIALPROFILESETUSAGE(#171,5,$); +#176= IFCRELASSOCIATESMATERIAL('3bTNkVsf9099xrALHA6WhF',$,'MatAssoc','Material Associates',(#180),#175); +#177= IFCCARTESIANPOINT((5000.0,0.0,0.0)); +#178= IFCAXIS2PLACEMENT3D(#177,$,$); +#179= IFCLOCALPLACEMENT(#12,#178); +#180= IFCCOLUMN('1JCvykjKH71R7_uck4n6hN',$,'PartialEllipse',$,$,#179,#186,$,$); +#181= IFCCARTESIANPOINT((0.0,0.0,2000.0)); +#182= IFCPOLYLINE((#15,#181)); +#183= IFCSHAPEREPRESENTATION(#80,'Axis','Curve3D',(#182)); +#184= IFCEXTRUDEDAREASOLID(#168,$,#83,2000.0); +#185= IFCSHAPEREPRESENTATION(#32,'Body','SweptSolid',(#184)); +#186= IFCPRODUCTDEFINITIONSHAPE($,$,(#183,#185)); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/bs_samples/extruded-solid.ifc b/files/ifc_files/bs_samples/extruded-solid.ifc new file mode 100644 index 000000000..93a8309e4 --- /dev/null +++ b/files/ifc_files/bs_samples/extruded-solid.ifc @@ -0,0 +1,96 @@ +ISO-10303-21; +HEADER; +/* NOTE a valid model view name has to be asserted, replacing 'NotAssigned' ----------------- */ +FILE_DESCRIPTION( + ( 'ViewDefinition [NotAssigned]' + ,'Comment [manual creation of example file]' + ) + ,'2;1'); +/* NOTE standard header information according to ISO 10303-21 ---------------------------------- */ +FILE_NAME( + 'extruded-solid.ifc', + '2012-06-18T18:00:00', + ('redacted'), + ('redacted'), + 'redacted', + 'redacted - redacted - 3.14159', + 'reference file created for the IFC4 specification'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* --------------------------------------------------------------------------------------------- */ +/* general entities required for all IFC data sets, defining the context for the exchange ------ */ +#100= IFCPROJECT('0xScRe4drECQ4DMSqUjd6d',#110,'proxy with swept solid',$,$,$,$,(#201),#301); + +/* single owner history sufficient if not otherwise required by the view definition ------------ */ +/* provides the person and application creating the data set, and the time it is created ------- */ +#110= IFCOWNERHISTORY(#111,#115,$,.ADDED.,1320688800,$,$,1320688800); +#111= IFCPERSONANDORGANIZATION(#112,#113,$); +#112= IFCPERSON($,'redacted','redacted',$,$,$,$,$); +#113= IFCORGANIZATION($,'redacted',$,$,$); +#115= IFCAPPLICATION(#113,'redacted','redacted','redacted'); + +/* each IFC data set containing geometry has to define a geometric representation context ------ */ +/* the attribute 'ContextType' has to be 'Model' for 3D model geometry ------------------------- */ +#201= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#210,$); +/* the attribute 'ContextIdentifier' has to be 'Body' for the main 3D shape representation ----- */ +#202= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#201,$,.MODEL_VIEW.,$); +#210= IFCAXIS2PLACEMENT3D(#901,$,$); + +/* each IFC data set containing geometry has to define at absolute minimum length and angle ---- */ +/* here length is milli metre as SI unit, and plane angle is 'degree' as non SI unit ----------- */ +#301= IFCUNITASSIGNMENT((#311,#312)); +#311= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#312= IFCCONVERSIONBASEDUNIT(#313,.PLANEANGLEUNIT.,'degree',#314); +#313= IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#314= IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453293),#315); +#315= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); + +/* each IFC data set containing elements in a building context has to include a building ------- */ +/* at absolute minimum (could have a site and stories as well) --------------------------------- */ +#500= IFCBUILDING('2FCZDorxHDT8NI01kdXi8P',$,'Test Building',$,$,#511,$,$,.ELEMENT.,$,$,$); +/* if the building is the uppermost spatial structure element it defines the absolut position -- */ +#511= IFCLOCALPLACEMENT($,#512); +/* no rotation - z and x axes set to '$' are therefore identical to "world coordinate system" -- */ +#512= IFCAXIS2PLACEMENT3D(#901,$,$); +/* if the building is the uppermost spatial structure element it is assigned to the project ---- */ +#519= IFCRELAGGREGATES('2YBqaV_8L15eWJ9DA1sGmT',$,$,$,#100,(#500)); + +/* shared coordinates - it is permissible to share common instances to reduce file size -------- */ +#901= IFCCARTESIANPOINT((0.,0.,0.)); +#902= IFCDIRECTION((1.,0.,0.)); +#903= IFCDIRECTION((0.,1.,0.)); +#904= IFCDIRECTION((0.,0.,1.)); +#905= IFCDIRECTION((-1.,0.,0.)); +#906= IFCDIRECTION((0.,-1.,0.)); +#907= IFCDIRECTION((0.,0.,-1.)); + +/* --------------------------------------------------------------------------------------------- */ +/* proxy element with swept solid shape representation, assigned to the building --------------- */ +#1000= IFCBUILDINGELEMENTPROXY('1kTvXnbbzCWw8lcMd1dR4o',$,'P-1','sample proxy',$,#1001,#1010,$,$); +/* proxy element placement relative to the building -------------------------------------------- */ +#1001= IFCLOCALPLACEMENT(#511,#1002); +/* set local placement to 1 meter on x-axis, and 0 on y, and 0 on z axes ----------------------- */ +/* no rotation - z and x axes set to '$' are therefore identical to those of building ---------- */ +#1002= IFCAXIS2PLACEMENT3D(#1003,$,$); +#1003= IFCCARTESIANPOINT((1000.,0.,0.)); +/* proxy element shape representation ---------------------------------------------------------- */ +#1010= IFCPRODUCTDEFINITIONSHAPE($,$,(#1020)); +/* a single shape representation of type 'SweptSolid' is included ------------------------------ */ +#1020= IFCSHAPEREPRESENTATION(#202,'Body','SweptSolid',(#1021)); +/* based on a profile (or cross section) of 1m by 1m being extruded by 2m ---------------------- */ +#1021= IFCEXTRUDEDAREASOLID(#1022,$,#1034,2000.); +#1022= IFCRECTANGLEPROFILEDEF(.AREA.,'1m x 1m rectangle',$,1000.,1000.); +/* extrusion body is placed centric with no rotation inside the object coordinate placement ---- */ +/* extrusion position z = default = (0.,0.,1.), x = default = (1.,0.,0.) ----------------------- */ +/* the default position, i.e. no re-positioning of the results, hence the position is NIL ------ */ +/* the extrusion if perpendicular to the profile - i.e. along the positive z-axis -------------- */ +#1034= IFCDIRECTION((0.,0.,1.)); + +/* proxy element assigned to the building ------------------------------------------------------ */ +#10000=IFCRELCONTAINEDINSPATIALSTRUCTURE('2TnxZkTXT08eDuMuhUUFNy',$,'Physical model',$,(#1000),#500); + + +ENDSEC; +END-ISO-10303-21; \ No newline at end of file diff --git a/files/ifc_files/bs_samples/polygonal-face-tessellation.ifc b/files/ifc_files/bs_samples/polygonal-face-tessellation.ifc new file mode 100644 index 000000000..d37f7ea01 --- /dev/null +++ b/files/ifc_files/bs_samples/polygonal-face-tessellation.ifc @@ -0,0 +1,79 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1'); +FILE_NAME( +/* name */ 'polygonal-face-tessellation.ifc', +/* time_stamp */ '2016-05-26T17:38:29', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.01,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#3058,#3059,#3060); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#6= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#7= IFCDIRECTION((1.0,0.0,0.0)); +#8= IFCDIRECTION((0.0,1.0,0.0)); +#9= IFCDIRECTION((0.0,0.0,1.0)); +#10= IFCCARTESIANPOINT((0.0,0.0)); +#11= IFCAXIS2PLACEMENT2D(#10,$); +#12= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#13= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#14= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +/* ------------------------------------------------------------------------- */ +/* polygonal face set geometry representation */ +#15= IFCCARTESIANPOINTLIST3D(((-10000.0,-10000.0,-10000.0),(10000.0,-10000.0,-10000.0),(10000.0,10000.0,-10000.0),(-10000.0,10000.0,-10000.0),(-10000.0,-10000.0,10000.0),(10000.0,-10000.0,10000.0),(10000.0,10000.0,10000.0),(-10000.0,10000.0,10000.0),(-5000.0,-5000.0,-5000.0),(5000.0,-5000.0,-5000.0),(5000.0,5000.0,-5000.0),(-5000.0,5000.0,-5000.0),(5000.0,-5000.0,10000.0),(-5000.0,-5000.0,10000.0),(5000.0,5000.0,10000.0),(-5000.0,5000.0,10000.0)),$); +#16= IFCINDEXEDPOLYGONALFACE((2,6,5,1)); +#17= IFCINDEXEDPOLYGONALFACE((3,7,6,2)); +#18= IFCINDEXEDPOLYGONALFACE((4,8,7,3)); +#19= IFCINDEXEDPOLYGONALFACE((1,5,8,4)); +#20= IFCINDEXEDPOLYGONALFACE((4,3,2,1)); +#21= IFCINDEXEDPOLYGONALFACEWITHVOIDS((6,7,8,5),((14,16,15,13))); +#22= IFCINDEXEDPOLYGONALFACE((13,10,9,14)); +#23= IFCINDEXEDPOLYGONALFACE((15,11,10,13)); +#24= IFCINDEXEDPOLYGONALFACE((16,12,11,15)); +#25= IFCINDEXEDPOLYGONALFACE((14,9,12,16)); +#26= IFCINDEXEDPOLYGONALFACE((9,10,11,12)); +#27= IFCPOLYGONALFACESET(#15,.T.,(#16,#17,#18,#19,#20,#21,#22,#23,#24,#25,#26),$); +#28= IFCSHAPEREPRESENTATION(#5,'Body','Tessellation',(#27)); +#29= IFCPRODUCTDEFINITIONSHAPE($,$,(#28)); +/* ------------------------------------------------------------------------- */ +/* assigned as shape representation to a proxy */ +#30= IFCBUILDINGELEMENTPROXY('1csV6umSb0px7vDxobGEN_',#3054,'NOTDEFINED',$,$,#32,#29,$,.NOTDEFINED.); +#31= IFCAXIS2PLACEMENT3D(#2,$,$); +#32= IFCLOCALPLACEMENT(#3047,#31); +#3047= IFCLOCALPLACEMENT($,#3); +/* ------------------------------------------------------------------------- */ +/* definition of the minimal building structure, units, owner history */ +#3048= IFCBUILDING('2tMIBeIVfClPSrbOosOJDI',#3054,'Grasshopper Building','GH Building',$,#3047,$,'GH Building',$,0.0,0.0,$); +#3049= IFCPERSONANDORGANIZATION(#3050,#3051,$); +#3050= IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#3051= IFCORGANIZATION($,'redacted',$,$,$); +#3052= IFCAPPLICATION(#3053,'redacted','redacted','redacted'); +#3053= IFCORGANIZATION($,'redacted',$,$,$); +#3054= IFCOWNERHISTORY(#3049,#3052,$,.ADDED.,1464284014,$,$,1464284014); +#3055= IFCRELCONTAINEDINSPATIALSTRUCTURE('3_FDRX0_zCNwiRAZP_mk8l',#3054,'Building','Building Container for Elements',(#30),#3048); +#3056= IFCAXIS2PLACEMENT3D(#2,$,$); +#3058= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3059= IFCDIRECTION((0.0,0.0,1.0)); +#3060= IFCDIRECTION((1.0,0.0,0.0)); +#3061= IFCPROJECT('37E0t9DhPDuPXM8sgxexaw',#3054,'Grasshopper Project',$,$,$,$,(#1),#3062); +#3062= IFCUNITASSIGNMENT((#3063,#13,#14,#3064,#3065)); +#3063= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#3064= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#3065= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#3066= IFCRELAGGREGATES('1jnXpsMarCZQ$Xxt61uMb4',#3054,'Project Container','Project Container for Buildings',#3061,(#3048)); +#3070= IFCAXIS2PLACEMENT3D(#2,$,$); +ENDSEC; + +END-ISO-10303-21; + + diff --git a/files/ifc_files/bs_samples/tessellation-with-image-texture.ifc b/files/ifc_files/bs_samples/tessellation-with-image-texture.ifc new file mode 100644 index 000000000..8a411b79a --- /dev/null +++ b/files/ifc_files/bs_samples/tessellation-with-image-texture.ifc @@ -0,0 +1,74 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('tessellation-with-image-texture.ifc','2016-05-06T03:47:49',(''),(''),'redacted','redacted - redacted - 3.14159',''); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; + +DATA; + +#1= IFCPROJECT('10kad3X9L7HRl25ZiOK$bx',$,'Project',$,$,$,$,(#2,#3),#4); +#2= IFCGEOMETRICREPRESENTATIONCONTEXT('3D','Model',3,1.0E-05,#6,$); +#3= IFCGEOMETRICREPRESENTATIONCONTEXT('2D','Plan',2,1.0E-05,#7,$); +#4= IFCUNITASSIGNMENT((#8,#9,#10,#11,#12,#13,#14,#15,#16)); +#6= IFCAXIS2PLACEMENT3D(#17,$,$); +#7= IFCAXIS2PLACEMENT3D(#18,$,$); +#8= IFCCONVERSIONBASEDUNIT(#19,.AREAUNIT.,'square inch',#20); +#9= IFCCONVERSIONBASEDUNIT(#21,.FORCEUNIT.,'pound-force',#22); +#10= IFCCONVERSIONBASEDUNIT(#23,.LENGTHUNIT.,'inch',#24); +#11= IFCCONVERSIONBASEDUNIT(#25,.MASSUNIT.,'pound',#26); +#12= IFCCONVERSIONBASEDUNIT(#27,.PLANEANGLEUNIT.,'degree',#28); +#13= IFCCONVERSIONBASEDUNIT(#29,.PRESSUREUNIT.,'pound-force per square inch',#30); +#14= IFCCONVERSIONBASEDUNITWITHOFFSET(#31,.THERMODYNAMICTEMPERATUREUNIT.,'Fahrenheit',#32,-459.67); +#15= IFCCONVERSIONBASEDUNIT(#33,.VOLUMEUNIT.,'cubic inch',#34); +#16= IFCMONETARYUNIT('USD'); +#17= IFCCARTESIANPOINT((0.,0.,0.)); +#18= IFCCARTESIANPOINT((0.,0.,0.)); +#19= IFCDIMENSIONALEXPONENTS(2,0,0,0,0,0,0); +#20= IFCMEASUREWITHUNIT(IFCAREAMEASURE(0.0006452),#35); +#21= IFCDIMENSIONALEXPONENTS(1,1,-2,0,0,0,0); +#22= IFCMEASUREWITHUNIT(IFCMASSMEASURE(4.44822162),#36); +#23= IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0); +#24= IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(0.0254),#37); +#25= IFCDIMENSIONALEXPONENTS(0,1,0,0,0,0,0); +#26= IFCMEASUREWITHUNIT(IFCMASSMEASURE(0.45359237),#38); +#27= IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#28= IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.0174532925199433),#39); +#29= IFCDIMENSIONALEXPONENTS(-1,1,-2,0,0,0,0); +#30= IFCMEASUREWITHUNIT(IFCPRESSUREMEASURE(6894.7572932),#40); +#31= IFCDIMENSIONALEXPONENTS(0,0,0,0,1,0,0); +#32= IFCMEASUREWITHUNIT(IFCTHERMODYNAMICTEMPERATUREMEASURE(1.8),#41); +#33= IFCDIMENSIONALEXPONENTS(3,0,0,0,0,0,0); +#34= IFCMEASUREWITHUNIT(IFCVOLUMEMEASURE(1.639E-05),#42); +#35= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#36= IFCSIUNIT(*,.FORCEUNIT.,$,.NEWTON.); +#37= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#38= IFCSIUNIT(*,.MASSUNIT.,.KILO.,.GRAM.); +#39= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#40= IFCSIUNIT(*,.PRESSUREUNIT.,$,.PASCAL.); +#41= IFCSIUNIT(*,.THERMODYNAMICTEMPERATUREUNIT.,$,.KELVIN.); +#42= IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); + +#5= IFCRELDECLARES('3FXPqqCWT9I9Gn7115PFHw',$,$,$,#1,(#43)); + +#43= IFCBOILERTYPE('2n5ASfQfT84eP9h$zLLJ4A',$,'boiler name',$,$,$,(#44),$,$,.NOTDEFINED.); +#44= IFCREPRESENTATIONMAP(#45,#46); +#45= IFCAXIS2PLACEMENT3D(#47,$,$); +#46= IFCSHAPEREPRESENTATION(#2,'Body','Tessellation',(#48)); +#47= IFCCARTESIANPOINT((0.,0.,0.)); +#48= IFCTRIANGULATEDFACESET(#49,((0.,0.,-1.),(0.,0.,1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,-1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(0.,0.,1.),(1.,0.,0.),(0.923879532511287,0.38268343236509,0.),(0.707106781186548,0.707106781186547,0.),(0.38268343236509,0.923879532511287,0.),(6.12303176911189E-17,1.,0.),(-0.38268343236509,0.923879532511287,0.),(-0.707106781186547,0.707106781186548,0.),(-0.923879532511287,0.38268343236509,0.),(-1.,1.22460635382238E-16,0.),(-0.923879532511287,-0.38268343236509,0.),(-0.707106781186548,-0.707106781186547,0.),(-0.38268343236509,-0.923879532511287,0.),(-1.83690953073357E-16,-1.,0.),(0.38268343236509,-0.923879532511287,0.),(0.707106781186547,-0.707106781186548,0.),(0.923879532511287,-0.38268343236509,0.),(1.,-2.44921270764475E-16,0.),(1.,0.,0.),(0.923879532511287,0.38268343236509,0.),(0.707106781186548,0.707106781186547,0.),(0.38268343236509,0.923879532511287,0.),(6.12303176911189E-17,1.,0.),(-0.38268343236509,0.923879532511287,0.),(-0.707106781186547,0.707106781186548,0.),(-0.923879532511287,0.38268343236509,0.),(-1.,1.22460635382238E-16,0.),(-0.923879532511287,-0.38268343236509,0.),(-0.707106781186548,-0.707106781186547,0.),(-0.38268343236509,-0.923879532511287,0.),(-1.83690953073357E-16,-1.,0.),(0.38268343236509,-0.923879532511287,0.),(0.707106781186547,-0.707106781186548,0.),(0.923879532511287,-0.38268343236509,0.),(1.,-2.44921270764475E-16,0.)),.T.,((1,4,3),(38,54,37),(38,55,54),(2,20,21),(1,5,4),(39,55,38),(39,56,55),(2,21,22),(1,6,5),(40,56,39),(40,57,56),(2,22,23),(1,7,6),(41,57,40),(41,58,57),(2,23,24),(1,8,7),(42,58,41),(42,59,58),(2,24,25),(1,9,8),(43,59,42),(43,60,59),(2,25,26),(1,10,9),(44,60,43),(44,61,60),(2,26,27),(1,11,10),(45,61,44),(45,62,61),(2,27,28),(1,12,11),(46,62,45),(46,63,62),(2,28,29),(1,13,12),(47,63,46),(47,64,63),(2,29,30),(1,14,13),(48,64,47),(48,65,64),(2,30,31),(1,15,14),(49,65,48),(49,66,65),(2,31,32),(1,16,15),(50,66,49),(50,67,66),(2,32,33),(1,17,16),(51,67,50),(51,68,67),(2,33,34),(1,18,17),(52,68,51),(52,69,68),(2,34,35),(1,19,18),(53,69,52),(53,70,69),(2,35,36)),$); +#49= IFCCARTESIANPOINTLIST3D(((0.,0.,0.),(0.,0.,48.),(24.,0.,0.),(22.1731087802709,9.18440237676215,0.),(16.9705627484771,16.9705627484771,0.),(9.18440237676216,22.1731087802709,0.),(1.46952762458685E-15,24.,0.),(-9.18440237676215,22.1731087802709,0.),(-16.9705627484771,16.9705627484771,0.),(-22.1731087802709,9.18440237676216,0.),(-24.,2.93905524917371E-15,0.),(-22.1731087802709,-9.18440237676215,0.),(-16.9705627484771,-16.9705627484771,0.),(-9.18440237676217,-22.1731087802709,0.),(-4.40858287376056E-15,-24.,0.),(9.18440237676216,-22.1731087802709,0.),(16.9705627484771,-16.9705627484771,0.),(22.1731087802709,-9.18440237676217,0.),(24.,-5.87811049834741E-15,0.),(24.,0.,48.),(22.1731087802709,9.18440237676215,48.),(16.9705627484771,16.9705627484771,48.),(9.18440237676216,22.1731087802709,48.),(1.46952762458685E-15,24.,48.),(-9.18440237676215,22.1731087802709,48.),(-16.9705627484771,16.9705627484771,48.),(-22.1731087802709,9.18440237676216,48.),(-24.,2.93905524917371E-15,48.),(-22.1731087802709,-9.18440237676215,48.),(-16.9705627484771,-16.9705627484771,48.),(-9.18440237676217,-22.1731087802709,48.),(-4.40858287376056E-15,-24.,48.),(9.18440237676216,-22.1731087802709,48.),(16.9705627484771,-16.9705627484771,48.),(22.1731087802709,-9.18440237676217,48.),(24.,-5.87811049834741E-15,48.),(24.,0.,0.),(22.1731087802709,9.18440237676215,0.),(16.9705627484771,16.9705627484771,0.),(9.18440237676216,22.1731087802709,0.),(1.46952762458685E-15,24.,0.),(-9.18440237676215,22.1731087802709,0.),(-16.9705627484771,16.9705627484771,0.),(-22.1731087802709,9.18440237676216,0.),(-24.,2.93905524917371E-15,0.),(-22.1731087802709,-9.18440237676215,0.),(-16.9705627484771,-16.9705627484771,0.),(-9.18440237676217,-22.1731087802709,0.),(-4.40858287376056E-15,-24.,0.),(9.18440237676216,-22.1731087802709,0.),(16.9705627484771,-16.9705627484771,0.),(22.1731087802709,-9.18440237676217,0.),(24.,-5.87811049834741E-15,0.),(24.,0.,48.),(22.1731087802709,9.18440237676215,48.),(16.9705627484771,16.9705627484771,48.),(9.18440237676216,22.1731087802709,48.),(1.46952762458685E-15,24.,48.),(-9.18440237676215,22.1731087802709,48.),(-16.9705627484771,16.9705627484771,48.),(-22.1731087802709,9.18440237676216,48.),(-24.,2.93905524917371E-15,48.),(-22.1731087802709,-9.18440237676215,48.),(-16.9705627484771,-16.9705627484771,48.),(-9.18440237676217,-22.1731087802709,48.),(-4.40858287376056E-15,-24.,48.),(9.18440237676216,-22.1731087802709,48.),(16.9705627484771,-16.9705627484771,48.),(22.1731087802709,-9.18440237676217,48.),(24.,-5.87811049834741E-15,48.)),$); +#50= IFCSTYLEDITEM(#48,(#52),$); +#51= IFCINDEXEDTRIANGLETEXTUREMAP((#57),#48,#53,((1,4,3),(38,54,37),(38,55,54),(2,20,21),(1,5,4),(39,55,38),(39,56,55),(2,21,22),(1,6,5),(40,56,39),(40,57,56),(2,22,23),(1,7,6),(41,57,40),(41,58,57),(2,23,24),(1,8,7),(42,58,41),(42,59,58),(2,24,25),(1,9,8),(43,59,42),(43,60,59),(2,25,26),(1,10,9),(44,60,43),(44,61,60),(2,26,27),(1,11,10),(45,61,44),(45,62,61),(2,27,28),(1,12,11),(46,62,45),(46,63,62),(2,28,29),(1,13,12),(47,63,46),(47,64,63),(2,29,30),(1,14,13),(48,64,47),(48,65,64),(2,30,31),(1,15,14),(49,65,48),(49,66,65),(2,31,32),(1,16,15),(50,66,49),(50,67,66),(2,32,33),(1,17,16),(51,67,50),(51,68,67),(2,33,34),(1,18,17),(52,68,51),(52,69,68),(2,34,35),(1,19,18),(53,69,52),(53,70,69),(2,35,36))); +#52= IFCSURFACESTYLE($,.POSITIVE.,(#54,#55)); +#53= IFCTEXTUREVERTEXLIST(((0.5,0.5),(0.5,0.5),(1.,0.5),(0.961939766255643,0.308658283817455),(0.853553390593274,0.146446609406726),(0.691341716182545,0.0380602337443566),(0.5,0.),(0.308658283817455,0.0380602337443566),(0.146446609406726,0.146446609406726),(0.0380602337443566,0.308658283817455),(0.,0.5),(0.0380602337443566,0.691341716182545),(0.146446609406726,0.853553390593274),(0.308658283817455,0.961939766255643),(0.5,1.),(0.691341716182545,0.961939766255643),(0.853553390593274,0.853553390593274),(0.961939766255643,0.691341716182545),(1.,0.5),(1.,0.5),(0.961939766255643,0.691341716182545),(0.853553390593274,0.853553390593274),(0.691341716182545,0.961939766255643),(0.5,1.),(0.308658283817455,0.961939766255643),(0.146446609406726,0.853553390593274),(0.0380602337443566,0.691341716182545),(0.,0.5),(0.0380602337443566,0.308658283817455),(0.146446609406726,0.146446609406726),(0.308658283817455,0.0380602337443567),(0.5,0.),(0.691341716182545,0.0380602337443567),(0.853553390593274,0.146446609406726),(0.961939766255643,0.308658283817455),(1.,0.5),(-0.25,0.),(-0.1875,0.),(-0.125,0.),(-0.0625,0.),(0.,0.),(0.0625,0.),(0.125,0.),(0.1875,0.),(0.25,0.),(0.3125,0.),(0.375,0.),(0.4375,0.),(0.5,0.),(0.5625,0.),(0.625,0.),(0.6875,0.),(0.75,0.),(-0.25,1.),(-0.1875,1.),(-0.125,1.),(-0.0625,1.),(0.,1.),(0.0625,1.),(0.125,1.),(0.1875,1.),(0.25,1.),(0.3125,1.),(0.375,1.),(0.4375,1.),(0.5,1.),(0.5625,1.),(0.625,1.),(0.6875,1.),(0.75,1.))); +#54= IFCSURFACESTYLERENDERING(#56,$,$,$,$,$,$,$,.NOTDEFINED.); +#55= IFCSURFACESTYLEWITHTEXTURES((#57)); +#56= IFCCOLOURRGB($,1.,1.,1.); +#57= IFCIMAGETEXTURE(.T.,.T.,'DIFFUSE',#58,$,'texture.png'); +#58= IFCCARTESIANTRANSFORMATIONOPERATOR2D(#59,$,#60,48.); +#59= IFCDIRECTION((1.,0.)); +#60= IFCCARTESIANPOINT((0.,0.)); +ENDSEC; + +END-ISO-10303-21; diff --git a/files/ifc_files/reinforcing-assembly.ifc b/files/ifc_files/reinforcing-assembly.ifc new file mode 100644 index 000000000..86c08cfd2 --- /dev/null +++ b/files/ifc_files/reinforcing-assembly.ifc @@ -0,0 +1,456 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'reinforcing-assembly.ifc', +/* time_stamp */ '2016-02-04T08:47:55', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* general entities required for all IFC data sets, defining the context for the exchange */ +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#2,$,$); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +/* defines the default building (as required as the minimum spatial element) */ +#50= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',#56,'IfcBuilding',$,$,$,$,$,$,$,$,$); +#51= IFCPERSONANDORGANIZATION(#52,#53,$); +#52= IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#53= IFCORGANIZATION($,'redacted',$,$,$); +#54= IFCAPPLICATION(#55,'redacted','redacted','redacted'); +#55= IFCORGANIZATION($,'redacted',$,$,$); +#56= IFCOWNERHISTORY(#51,#54,$,.ADDED.,1454575675,$,$,1454575675); +#57= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',#56,'Building','Building Container for Elements',(#222),#50); +#58= IFCAXIS2PLACEMENT3D(#2,$,$); +#100= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',#56,'IfcProject',$,$,$,$,(#1),#101); +#101= IFCUNITASSIGNMENT((#102,#103,#104)); +#102= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#103= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#104= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#105= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',#56,'Project Container','Project Container for Buildings',#100,(#50)); +#200= IFCDOCUMENTREFERENCE($,'MyCodeISO3766','MyReinforcementCode',$,$); +#201= IFCRELASSOCIATESDOCUMENT('1R7R97$uLAAv4wci$KGwn8',#56,$,$,(#100),#200); +#202= IFCMATERIAL('ReinforcingSteel',$,$); +#203= IFCRELASSOCIATESMATERIAL('3gfVO40P5EfQyKZ_bF0R$6',#56,'MatAssoc','Material Associates',(#210),#202); +#204= IFCCARTESIANPOINTLIST3D(((-69.0,0.0,-122.0),(-69.0,0.0,-79.0),(-54.9411254969544,0.0,-45.0588745030457),(-21.0000000000001,0.0,-31.0),(21.0,0.0,-31.0),(54.9411254969543,0.0,-45.0588745030456),(69.0,0.0,-78.9999999999999),(69.0,0.00000000000000089,-321.0),(54.993978595716,1.21791490472038,-354.941125496954),(21.1804517666064,4.1582215855126,-369.0),(-20.6616529376114,7.79666547283599,-369.0),(-54.4751797667207,10.7369721536282,-354.941125496954),(-68.4812011710042,11.9548870583485,-320.999999999999),(-69.0,12.0,-79.0),(-54.9411254969544,12.0,-45.0588745030457),(-21.0000000000001,12.0,-31.0),(21.0,12.0,-31.0),(54.9411254969543,12.0,-45.0588745030456),(69.0,12.0,-78.9999999999999),(69.0,12.0,-122.0)),$); +#205= IFCINDEXEDPOLYCURVE(#204,(IFCLINEINDEX((1,2)),IFCARCINDEX((2,3,4)),IFCLINEINDEX((4,5)),IFCARCINDEX((5,6,7)),IFCLINEINDEX((7,8)),IFCARCINDEX((8,9,10)),IFCLINEINDEX((10,11)),IFCARCINDEX((11,12,13)),IFCLINEINDEX((13,14)),IFCARCINDEX((14,15,16)),IFCLINEINDEX((16,17)),IFCARCINDEX((17,18,19)),IFCLINEINDEX((19,20))),.F.); +#206= IFCSWEPTDISKSOLID(#205,6.0,$,$,$); +#207= IFCREPRESENTATIONMAP(#208,#209); +#208= IFCAXIS2PLACEMENT3D(#2,$,$); +#209= IFCSHAPEREPRESENTATION(#5,'Body','SolidModel',(#206)); +#210= IFCREINFORCINGBARTYPE('0jMRtfHYXE7u4s_CQ2uVE9',#56,'12 Diameter Ligature',$,$,$,(#207),$,$,.LIGATURE.,12.0,113.097335529233,1150.0,.TEXTURED.,$,$); +#211= IFCRELDEFINESBYTYPE('1iAfl2ERbFmwi7uniy1H7j',#56,$,$,(#248,#261,#272,#283,#294,#305,#316,#327,#338,#349,#360,#371,#382,#393,#404,#415,#426,#437,#448,#459,#470,#481,#492,#503,#514,#525,#536,#547,#558,#569,#580,#591,#602,#613),#210); +#212= IFCRELDECLARES('2MRDPYmlP17vf3Xv8K3i6b',#56,$,$,#100,(#210,#220)); +#213= IFCMATERIAL('Concrete','Concrete',$); +#215= IFCRECTANGLEPROFILEDEF(.AREA.,'400x200RC',$,200.0,400.0); +#216= IFCMATERIALPROFILE('400x200RC',$,#213,#215,0,$); +#218= IFCMATERIALPROFILESET('400x200RC',$,(#216),$); +#219= IFCRELASSOCIATESMATERIAL('2ZEgyI2v184hwa$_diRqS9',#56,'MatAssoc','Material Associates',(#220),#218); +#220= IFCBEAMTYPE('3bdpqVuWTCbxJ2S3ODYv6q',#56,'400x200RC',$,$,$,$,$,$,.BEAM.); +#221= IFCRELDEFINESBYTYPE('2oaQVVf79BrwRouvtRuQVg',#56,$,$,(#222),#220); +#222= IFCBEAM('1yjQ2DwLnCC8k3i3X6D_ut',#56,$,$,$,#223,#238,$,$); +#223= IFCLOCALPLACEMENT($,#224); +#224= IFCAXIS2PLACEMENT3D(#2,#225,#226); +#225= IFCDIRECTION((0.0,1.0,0.0)); +#226= IFCDIRECTION((-1.0,0.0,0.0)); +#227= IFCCARTESIANPOINT((0.0,0.0,5000.0)); +#228= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#229= IFCPOLYLINE((#228,#227)); +#230= IFCSHAPEREPRESENTATION(#5,'Axis','Curve3D',(#229)); +#231= IFCMATERIALPROFILESETUSAGE(#218,8,$); +#232= IFCRELASSOCIATESMATERIAL('3DWeleqqjEG9KshbOZXUdY',#56,'MatAssoc','Material Associates',(#222),#231); +#233= IFCDIRECTION((0.0,0.0,1.0)); +#234= IFCEXTRUDEDAREASOLID(#215,#235,#233,5000.0); +#235= IFCAXIS2PLACEMENT3D(#236,$,$); +#236= IFCCARTESIANPOINT((0.0,-200.0,0.0)); +#237= IFCSHAPEREPRESENTATION(#5,'Body','SweptSolid',(#234)); +#238= IFCPRODUCTDEFINITIONSHAPE($,$,(#230,#237)); +#239= IFCELEMENTASSEMBLY('0Q1tCJWdj4kOkZUg7rkf2h',#56,$,$,$,$,$,$,.FACTORY.,.REINFORCEMENT_UNIT.); +#240= IFCDIRECTION((1.0,0.0,0.0)); +#241= IFCDIRECTION((0.0,1.0,0.0)); +#242= IFCCARTESIANPOINT((0.0,25.0,0.0)); +#243= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#240,#241,#242,1.0,#244); +#244= IFCDIRECTION((0.0,0.0,1.0)); +#245= IFCMAPPEDITEM(#207,#243); +#246= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#245)); +#247= IFCPRODUCTDEFINITIONSHAPE($,$,(#246)); +#248= IFCREINFORCINGBAR('0ohBfsArr3ruXYxacT4yl5',#56,$,$,$,#252,#247,$,$,$,$,$,$,$); +#249= IFCRELAGGREGATES('1b1SnKocD0WRevlg8Aqhj5',#56,'BEAM Container','BEAM Container for Elements',#222,(#239)); +#250= IFCRELAGGREGATES('1WdB196Kb72f_pKgj5rklU',#56,'ELEMENTASSEMBLY Container','ELEMENTASSEMBLY Container for Elements',#239,(#248,#261,#272,#283,#294,#305,#316,#327,#338,#349,#360,#371,#382,#393,#404,#415,#426,#437,#448,#459,#470,#481,#492,#503,#514,#525,#536,#547,#558,#569,#580,#591,#602,#613)); +#251= IFCAXIS2PLACEMENT3D(#2,$,$); +#252= IFCLOCALPLACEMENT($,#251); +#253= IFCDIRECTION((1.0,0.0,0.0)); +#254= IFCDIRECTION((0.0,1.0,0.0)); +#255= IFCCARTESIANPOINT((0.0,175.0,0.0)); +#256= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#253,#254,#255,1.0,#257); +#257= IFCDIRECTION((0.0,0.0,1.0)); +#258= IFCMAPPEDITEM(#207,#256); +#259= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#258)); +#260= IFCPRODUCTDEFINITIONSHAPE($,$,(#259)); +#261= IFCREINFORCINGBAR('3YrK7RbE122fNRsP5djFAe',#56,$,$,$,#263,#260,$,$,$,$,$,$,$); +#262= IFCAXIS2PLACEMENT3D(#2,$,$); +#263= IFCLOCALPLACEMENT($,#262); +#264= IFCDIRECTION((1.0,0.0,0.0)); +#265= IFCDIRECTION((0.0,1.0,0.0)); +#266= IFCCARTESIANPOINT((0.0,325.0,0.0)); +#267= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#264,#265,#266,1.0,#268); +#268= IFCDIRECTION((0.0,0.0,1.0)); +#269= IFCMAPPEDITEM(#207,#267); +#270= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#269)); +#271= IFCPRODUCTDEFINITIONSHAPE($,$,(#270)); +#272= IFCREINFORCINGBAR('0wxAc63nj5AezFhfks7wLL',#56,$,$,$,#274,#271,$,$,$,$,$,$,$); +#273= IFCAXIS2PLACEMENT3D(#2,$,$); +#274= IFCLOCALPLACEMENT($,#273); +#275= IFCDIRECTION((1.0,0.0,0.0)); +#276= IFCDIRECTION((0.0,1.0,0.0)); +#277= IFCCARTESIANPOINT((0.0,475.0,0.0)); +#278= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#275,#276,#277,1.0,#279); +#279= IFCDIRECTION((0.0,0.0,1.0)); +#280= IFCMAPPEDITEM(#207,#278); +#281= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#280)); +#282= IFCPRODUCTDEFINITIONSHAPE($,$,(#281)); +#283= IFCREINFORCINGBAR('0bsov2wZL6tRRZmKy4vuUU',#56,$,$,$,#285,#282,$,$,$,$,$,$,$); +#284= IFCAXIS2PLACEMENT3D(#2,$,$); +#285= IFCLOCALPLACEMENT($,#284); +#286= IFCDIRECTION((1.0,0.0,0.0)); +#287= IFCDIRECTION((0.0,1.0,0.0)); +#288= IFCCARTESIANPOINT((0.0,625.0,0.0)); +#289= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#286,#287,#288,1.0,#290); +#290= IFCDIRECTION((0.0,0.0,1.0)); +#291= IFCMAPPEDITEM(#207,#289); +#292= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#291)); +#293= IFCPRODUCTDEFINITIONSHAPE($,$,(#292)); +#294= IFCREINFORCINGBAR('3qrgfIBb92ZegJTle7jou3',#56,$,$,$,#296,#293,$,$,$,$,$,$,$); +#295= IFCAXIS2PLACEMENT3D(#2,$,$); +#296= IFCLOCALPLACEMENT($,#295); +#297= IFCDIRECTION((1.0,0.0,0.0)); +#298= IFCDIRECTION((0.0,1.0,0.0)); +#299= IFCCARTESIANPOINT((0.0,775.0,0.0)); +#300= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#297,#298,#299,1.0,#301); +#301= IFCDIRECTION((0.0,0.0,1.0)); +#302= IFCMAPPEDITEM(#207,#300); +#303= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#302)); +#304= IFCPRODUCTDEFINITIONSHAPE($,$,(#303)); +#305= IFCREINFORCINGBAR('16m6R3JeT83fJPCze2yU$a',#56,$,$,$,#307,#304,$,$,$,$,$,$,$); +#306= IFCAXIS2PLACEMENT3D(#2,$,$); +#307= IFCLOCALPLACEMENT($,#306); +#308= IFCDIRECTION((1.0,0.0,0.0)); +#309= IFCDIRECTION((0.0,1.0,0.0)); +#310= IFCCARTESIANPOINT((0.0,925.0,0.0)); +#311= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#308,#309,#310,1.0,#312); +#312= IFCDIRECTION((0.0,0.0,1.0)); +#313= IFCMAPPEDITEM(#207,#311); +#314= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#313)); +#315= IFCPRODUCTDEFINITIONSHAPE($,$,(#314)); +#316= IFCREINFORCINGBAR('2SGIIYjSbCuu3HVwoLt1yh',#56,$,$,$,#318,#315,$,$,$,$,$,$,$); +#317= IFCAXIS2PLACEMENT3D(#2,$,$); +#318= IFCLOCALPLACEMENT($,#317); +#319= IFCDIRECTION((1.0,0.0,0.0)); +#320= IFCDIRECTION((0.0,1.0,0.0)); +#321= IFCCARTESIANPOINT((0.0,1075.0,0.0)); +#322= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#319,#320,#321,1.0,#323); +#323= IFCDIRECTION((0.0,0.0,1.0)); +#324= IFCMAPPEDITEM(#207,#322); +#325= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#324)); +#326= IFCPRODUCTDEFINITIONSHAPE($,$,(#325)); +#327= IFCREINFORCINGBAR('0PsLby6eL8_hVEt4QwK0lZ',#56,$,$,$,#329,#326,$,$,$,$,$,$,$); +#328= IFCAXIS2PLACEMENT3D(#2,$,$); +#329= IFCLOCALPLACEMENT($,#328); +#330= IFCDIRECTION((1.0,0.0,0.0)); +#331= IFCDIRECTION((0.0,1.0,0.0)); +#332= IFCCARTESIANPOINT((0.0,1225.0,0.0)); +#333= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#330,#331,#332,1.0,#334); +#334= IFCDIRECTION((0.0,0.0,1.0)); +#335= IFCMAPPEDITEM(#207,#333); +#336= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#335)); +#337= IFCPRODUCTDEFINITIONSHAPE($,$,(#336)); +#338= IFCREINFORCINGBAR('1325VJou5AngWp1djcV0hL',#56,$,$,$,#340,#337,$,$,$,$,$,$,$); +#339= IFCAXIS2PLACEMENT3D(#2,$,$); +#340= IFCLOCALPLACEMENT($,#339); +#341= IFCDIRECTION((1.0,0.0,0.0)); +#342= IFCDIRECTION((0.0,1.0,0.0)); +#343= IFCCARTESIANPOINT((0.0,1375.0,0.0)); +#344= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#341,#342,#343,1.0,#345); +#345= IFCDIRECTION((0.0,0.0,1.0)); +#346= IFCMAPPEDITEM(#207,#344); +#347= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#346)); +#348= IFCPRODUCTDEFINITIONSHAPE($,$,(#347)); +#349= IFCREINFORCINGBAR('20zj_$BcH74xRgR4bDrLNb',#56,$,$,$,#351,#348,$,$,$,$,$,$,$); +#350= IFCAXIS2PLACEMENT3D(#2,$,$); +#351= IFCLOCALPLACEMENT($,#350); +#352= IFCDIRECTION((1.0,0.0,0.0)); +#353= IFCDIRECTION((0.0,1.0,0.0)); +#354= IFCCARTESIANPOINT((0.0,1525.0,0.0)); +#355= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#352,#353,#354,1.0,#356); +#356= IFCDIRECTION((0.0,0.0,1.0)); +#357= IFCMAPPEDITEM(#207,#355); +#358= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#357)); +#359= IFCPRODUCTDEFINITIONSHAPE($,$,(#358)); +#360= IFCREINFORCINGBAR('3M4SfEMtHEJukgZR4hw$eV',#56,$,$,$,#362,#359,$,$,$,$,$,$,$); +#361= IFCAXIS2PLACEMENT3D(#2,$,$); +#362= IFCLOCALPLACEMENT($,#361); +#363= IFCDIRECTION((1.0,0.0,0.0)); +#364= IFCDIRECTION((0.0,1.0,0.0)); +#365= IFCCARTESIANPOINT((0.0,1675.0,0.0)); +#366= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#363,#364,#365,1.0,#367); +#367= IFCDIRECTION((0.0,0.0,1.0)); +#368= IFCMAPPEDITEM(#207,#366); +#369= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#368)); +#370= IFCPRODUCTDEFINITIONSHAPE($,$,(#369)); +#371= IFCREINFORCINGBAR('23BYnIaOLBZPVTrKVEDJiy',#56,$,$,$,#373,#370,$,$,$,$,$,$,$); +#372= IFCAXIS2PLACEMENT3D(#2,$,$); +#373= IFCLOCALPLACEMENT($,#372); +#374= IFCDIRECTION((1.0,0.0,0.0)); +#375= IFCDIRECTION((0.0,1.0,0.0)); +#376= IFCCARTESIANPOINT((0.0,1825.0,0.0)); +#377= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#374,#375,#376,1.0,#378); +#378= IFCDIRECTION((0.0,0.0,1.0)); +#379= IFCMAPPEDITEM(#207,#377); +#380= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#379)); +#381= IFCPRODUCTDEFINITIONSHAPE($,$,(#380)); +#382= IFCREINFORCINGBAR('2XulRByDL8ugyo4Uqv9rJr',#56,$,$,$,#384,#381,$,$,$,$,$,$,$); +#383= IFCAXIS2PLACEMENT3D(#2,$,$); +#384= IFCLOCALPLACEMENT($,#383); +#385= IFCDIRECTION((1.0,0.0,0.0)); +#386= IFCDIRECTION((0.0,1.0,0.0)); +#387= IFCCARTESIANPOINT((0.0,1975.0,0.0)); +#388= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#385,#386,#387,1.0,#389); +#389= IFCDIRECTION((0.0,0.0,1.0)); +#390= IFCMAPPEDITEM(#207,#388); +#391= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#390)); +#392= IFCPRODUCTDEFINITIONSHAPE($,$,(#391)); +#393= IFCREINFORCINGBAR('2xvQMSga96XOT3VeCS6ZsK',#56,$,$,$,#395,#392,$,$,$,$,$,$,$); +#394= IFCAXIS2PLACEMENT3D(#2,$,$); +#395= IFCLOCALPLACEMENT($,#394); +#396= IFCDIRECTION((1.0,0.0,0.0)); +#397= IFCDIRECTION((0.0,1.0,0.0)); +#398= IFCCARTESIANPOINT((0.0,2125.0,0.0)); +#399= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#396,#397,#398,1.0,#400); +#400= IFCDIRECTION((0.0,0.0,1.0)); +#401= IFCMAPPEDITEM(#207,#399); +#402= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#401)); +#403= IFCPRODUCTDEFINITIONSHAPE($,$,(#402)); +#404= IFCREINFORCINGBAR('2gUE6_w3j77f8YJGz_2RMl',#56,$,$,$,#406,#403,$,$,$,$,$,$,$); +#405= IFCAXIS2PLACEMENT3D(#2,$,$); +#406= IFCLOCALPLACEMENT($,#405); +#407= IFCDIRECTION((1.0,0.0,0.0)); +#408= IFCDIRECTION((0.0,1.0,0.0)); +#409= IFCCARTESIANPOINT((0.0,2275.0,0.0)); +#410= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#407,#408,#409,1.0,#411); +#411= IFCDIRECTION((0.0,0.0,1.0)); +#412= IFCMAPPEDITEM(#207,#410); +#413= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#412)); +#414= IFCPRODUCTDEFINITIONSHAPE($,$,(#413)); +#415= IFCREINFORCINGBAR('0J0dRL4tT93REAabfASDom',#56,$,$,$,#417,#414,$,$,$,$,$,$,$); +#416= IFCAXIS2PLACEMENT3D(#2,$,$); +#417= IFCLOCALPLACEMENT($,#416); +#418= IFCDIRECTION((1.0,0.0,0.0)); +#419= IFCDIRECTION((0.0,1.0,0.0)); +#420= IFCCARTESIANPOINT((0.0,2425.0,0.0)); +#421= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#418,#419,#420,1.0,#422); +#422= IFCDIRECTION((0.0,0.0,1.0)); +#423= IFCMAPPEDITEM(#207,#421); +#424= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#423)); +#425= IFCPRODUCTDEFINITIONSHAPE($,$,(#424)); +#426= IFCREINFORCINGBAR('048RJ151b81PqODsTMD4EA',#56,$,$,$,#428,#425,$,$,$,$,$,$,$); +#427= IFCAXIS2PLACEMENT3D(#2,$,$); +#428= IFCLOCALPLACEMENT($,#427); +#429= IFCDIRECTION((1.0,0.0,0.0)); +#430= IFCDIRECTION((0.0,1.0,0.0)); +#431= IFCCARTESIANPOINT((0.0,2575.0,0.0)); +#432= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#429,#430,#431,1.0,#433); +#433= IFCDIRECTION((0.0,0.0,1.0)); +#434= IFCMAPPEDITEM(#207,#432); +#435= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#434)); +#436= IFCPRODUCTDEFINITIONSHAPE($,$,(#435)); +#437= IFCREINFORCINGBAR('3hXx9Kb6b5bvjgr9pwvpz0',#56,$,$,$,#439,#436,$,$,$,$,$,$,$); +#438= IFCAXIS2PLACEMENT3D(#2,$,$); +#439= IFCLOCALPLACEMENT($,#438); +#440= IFCDIRECTION((1.0,0.0,0.0)); +#441= IFCDIRECTION((0.0,1.0,0.0)); +#442= IFCCARTESIANPOINT((0.0,2725.0,0.0)); +#443= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#440,#441,#442,1.0,#444); +#444= IFCDIRECTION((0.0,0.0,1.0)); +#445= IFCMAPPEDITEM(#207,#443); +#446= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#445)); +#447= IFCPRODUCTDEFINITIONSHAPE($,$,(#446)); +#448= IFCREINFORCINGBAR('0FmUHg8ZX0ZfY$0f5nkM2l',#56,$,$,$,#450,#447,$,$,$,$,$,$,$); +#449= IFCAXIS2PLACEMENT3D(#2,$,$); +#450= IFCLOCALPLACEMENT($,#449); +#451= IFCDIRECTION((1.0,0.0,0.0)); +#452= IFCDIRECTION((0.0,1.0,0.0)); +#453= IFCCARTESIANPOINT((0.0,2875.0,0.0)); +#454= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#451,#452,#453,1.0,#455); +#455= IFCDIRECTION((0.0,0.0,1.0)); +#456= IFCMAPPEDITEM(#207,#454); +#457= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#456)); +#458= IFCPRODUCTDEFINITIONSHAPE($,$,(#457)); +#459= IFCREINFORCINGBAR('2_zvpwRdvAuRiTlHXX$Qp8',#56,$,$,$,#461,#458,$,$,$,$,$,$,$); +#460= IFCAXIS2PLACEMENT3D(#2,$,$); +#461= IFCLOCALPLACEMENT($,#460); +#462= IFCDIRECTION((1.0,0.0,0.0)); +#463= IFCDIRECTION((0.0,1.0,0.0)); +#464= IFCCARTESIANPOINT((0.0,3025.0,0.0)); +#465= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#462,#463,#464,1.0,#466); +#466= IFCDIRECTION((0.0,0.0,1.0)); +#467= IFCMAPPEDITEM(#207,#465); +#468= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#467)); +#469= IFCPRODUCTDEFINITIONSHAPE($,$,(#468)); +#470= IFCREINFORCINGBAR('1mhkXHKfX6PxdS2vZn17wX',#56,$,$,$,#472,#469,$,$,$,$,$,$,$); +#471= IFCAXIS2PLACEMENT3D(#2,$,$); +#472= IFCLOCALPLACEMENT($,#471); +#473= IFCDIRECTION((1.0,0.0,0.0)); +#474= IFCDIRECTION((0.0,1.0,0.0)); +#475= IFCCARTESIANPOINT((0.0,3175.0,0.0)); +#476= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#473,#474,#475,1.0,#477); +#477= IFCDIRECTION((0.0,0.0,1.0)); +#478= IFCMAPPEDITEM(#207,#476); +#479= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#478)); +#480= IFCPRODUCTDEFINITIONSHAPE($,$,(#479)); +#481= IFCREINFORCINGBAR('0CeIQzUqP5qOOeAjMtH2OX',#56,$,$,$,#483,#480,$,$,$,$,$,$,$); +#482= IFCAXIS2PLACEMENT3D(#2,$,$); +#483= IFCLOCALPLACEMENT($,#482); +#484= IFCDIRECTION((1.0,0.0,0.0)); +#485= IFCDIRECTION((0.0,1.0,0.0)); +#486= IFCCARTESIANPOINT((0.0,3325.0,0.0)); +#487= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#484,#485,#486,1.0,#488); +#488= IFCDIRECTION((0.0,0.0,1.0)); +#489= IFCMAPPEDITEM(#207,#487); +#490= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#489)); +#491= IFCPRODUCTDEFINITIONSHAPE($,$,(#490)); +#492= IFCREINFORCINGBAR('3shtoAQL5BAhvwA_1Ph$lC',#56,$,$,$,#494,#491,$,$,$,$,$,$,$); +#493= IFCAXIS2PLACEMENT3D(#2,$,$); +#494= IFCLOCALPLACEMENT($,#493); +#495= IFCDIRECTION((1.0,0.0,0.0)); +#496= IFCDIRECTION((0.0,1.0,0.0)); +#497= IFCCARTESIANPOINT((0.0,3475.0,0.0)); +#498= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#495,#496,#497,1.0,#499); +#499= IFCDIRECTION((0.0,0.0,1.0)); +#500= IFCMAPPEDITEM(#207,#498); +#501= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#500)); +#502= IFCPRODUCTDEFINITIONSHAPE($,$,(#501)); +#503= IFCREINFORCINGBAR('22j4RNKqD2IBRDGig5eaCF',#56,$,$,$,#505,#502,$,$,$,$,$,$,$); +#504= IFCAXIS2PLACEMENT3D(#2,$,$); +#505= IFCLOCALPLACEMENT($,#504); +#506= IFCDIRECTION((1.0,0.0,0.0)); +#507= IFCDIRECTION((0.0,1.0,0.0)); +#508= IFCCARTESIANPOINT((0.0,3625.0,0.0)); +#509= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#506,#507,#508,1.0,#510); +#510= IFCDIRECTION((0.0,0.0,1.0)); +#511= IFCMAPPEDITEM(#207,#509); +#512= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#511)); +#513= IFCPRODUCTDEFINITIONSHAPE($,$,(#512)); +#514= IFCREINFORCINGBAR('3Wvu6qGJH4ChhTV3pl9CGh',#56,$,$,$,#516,#513,$,$,$,$,$,$,$); +#515= IFCAXIS2PLACEMENT3D(#2,$,$); +#516= IFCLOCALPLACEMENT($,#515); +#517= IFCDIRECTION((1.0,0.0,0.0)); +#518= IFCDIRECTION((0.0,1.0,0.0)); +#519= IFCCARTESIANPOINT((0.0,3775.0,0.0)); +#520= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#517,#518,#519,1.0,#521); +#521= IFCDIRECTION((0.0,0.0,1.0)); +#522= IFCMAPPEDITEM(#207,#520); +#523= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#522)); +#524= IFCPRODUCTDEFINITIONSHAPE($,$,(#523)); +#525= IFCREINFORCINGBAR('37Qrf07Iz3tRMbSxEA4ynH',#56,$,$,$,#527,#524,$,$,$,$,$,$,$); +#526= IFCAXIS2PLACEMENT3D(#2,$,$); +#527= IFCLOCALPLACEMENT($,#526); +#528= IFCDIRECTION((1.0,0.0,0.0)); +#529= IFCDIRECTION((0.0,1.0,0.0)); +#530= IFCCARTESIANPOINT((0.0,3925.0,0.0)); +#531= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#528,#529,#530,1.0,#532); +#532= IFCDIRECTION((0.0,0.0,1.0)); +#533= IFCMAPPEDITEM(#207,#531); +#534= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#533)); +#535= IFCPRODUCTDEFINITIONSHAPE($,$,(#534)); +#536= IFCREINFORCINGBAR('2gelqZ1Wv8BvCy6TstVGkd',#56,$,$,$,#538,#535,$,$,$,$,$,$,$); +#537= IFCAXIS2PLACEMENT3D(#2,$,$); +#538= IFCLOCALPLACEMENT($,#537); +#539= IFCDIRECTION((1.0,0.0,0.0)); +#540= IFCDIRECTION((0.0,1.0,0.0)); +#541= IFCCARTESIANPOINT((0.0,4075.0,0.0)); +#542= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#539,#540,#541,1.0,#543); +#543= IFCDIRECTION((0.0,0.0,1.0)); +#544= IFCMAPPEDITEM(#207,#542); +#545= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#544)); +#546= IFCPRODUCTDEFINITIONSHAPE($,$,(#545)); +#547= IFCREINFORCINGBAR('1Q21dHc_X7eRppCHrT69Vb',#56,$,$,$,#549,#546,$,$,$,$,$,$,$); +#548= IFCAXIS2PLACEMENT3D(#2,$,$); +#549= IFCLOCALPLACEMENT($,#548); +#550= IFCDIRECTION((1.0,0.0,0.0)); +#551= IFCDIRECTION((0.0,1.0,0.0)); +#552= IFCCARTESIANPOINT((0.0,4225.0,0.0)); +#553= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#550,#551,#552,1.0,#554); +#554= IFCDIRECTION((0.0,0.0,1.0)); +#555= IFCMAPPEDITEM(#207,#553); +#556= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#555)); +#557= IFCPRODUCTDEFINITIONSHAPE($,$,(#556)); +#558= IFCREINFORCINGBAR('0e6Wc08NLD59ueqCAK1gxp',#56,$,$,$,#560,#557,$,$,$,$,$,$,$); +#559= IFCAXIS2PLACEMENT3D(#2,$,$); +#560= IFCLOCALPLACEMENT($,#559); +#561= IFCDIRECTION((1.0,0.0,0.0)); +#562= IFCDIRECTION((0.0,1.0,0.0)); +#563= IFCCARTESIANPOINT((0.0,4375.0,0.0)); +#564= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#561,#562,#563,1.0,#565); +#565= IFCDIRECTION((0.0,0.0,1.0)); +#566= IFCMAPPEDITEM(#207,#564); +#567= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#566)); +#568= IFCPRODUCTDEFINITIONSHAPE($,$,(#567)); +#569= IFCREINFORCINGBAR('3xdMOSZMj3cBOV_QTbXZha',#56,$,$,$,#571,#568,$,$,$,$,$,$,$); +#570= IFCAXIS2PLACEMENT3D(#2,$,$); +#571= IFCLOCALPLACEMENT($,#570); +#572= IFCDIRECTION((1.0,0.0,0.0)); +#573= IFCDIRECTION((0.0,1.0,0.0)); +#574= IFCCARTESIANPOINT((0.0,4525.0,0.0)); +#575= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#572,#573,#574,1.0,#576); +#576= IFCDIRECTION((0.0,0.0,1.0)); +#577= IFCMAPPEDITEM(#207,#575); +#578= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#577)); +#579= IFCPRODUCTDEFINITIONSHAPE($,$,(#578)); +#580= IFCREINFORCINGBAR('1r_U9JTkHDWwkv_nfWFHVe',#56,$,$,$,#582,#579,$,$,$,$,$,$,$); +#581= IFCAXIS2PLACEMENT3D(#2,$,$); +#582= IFCLOCALPLACEMENT($,#581); +#583= IFCDIRECTION((1.0,0.0,0.0)); +#584= IFCDIRECTION((0.0,1.0,0.0)); +#585= IFCCARTESIANPOINT((0.0,4675.0,0.0)); +#586= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#583,#584,#585,1.0,#587); +#587= IFCDIRECTION((0.0,0.0,1.0)); +#588= IFCMAPPEDITEM(#207,#586); +#589= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#588)); +#590= IFCPRODUCTDEFINITIONSHAPE($,$,(#589)); +#591= IFCREINFORCINGBAR('29I7_S2fT3WRD4zPH4YjmD',#56,$,$,$,#593,#590,$,$,$,$,$,$,$); +#592= IFCAXIS2PLACEMENT3D(#2,$,$); +#593= IFCLOCALPLACEMENT($,#592); +#594= IFCDIRECTION((1.0,0.0,0.0)); +#595= IFCDIRECTION((0.0,1.0,0.0)); +#596= IFCCARTESIANPOINT((0.0,4825.0,0.0)); +#597= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#594,#595,#596,1.0,#598); +#598= IFCDIRECTION((0.0,0.0,1.0)); +#599= IFCMAPPEDITEM(#207,#597); +#600= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#599)); +#601= IFCPRODUCTDEFINITIONSHAPE($,$,(#600)); +#602= IFCREINFORCINGBAR('0$ciATTaP17PJMHQD0$N3Y',#56,$,$,$,#604,#601,$,$,$,$,$,$,$); +#603= IFCAXIS2PLACEMENT3D(#2,$,$); +#604= IFCLOCALPLACEMENT($,#603); +#605= IFCDIRECTION((1.0,0.0,0.0)); +#606= IFCDIRECTION((0.0,1.0,0.0)); +#607= IFCCARTESIANPOINT((0.0,4975.0,0.0)); +#608= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#605,#606,#607,1.0,#609); +#609= IFCDIRECTION((0.0,0.0,1.0)); +#610= IFCMAPPEDITEM(#207,#608); +#611= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#610)); +#612= IFCPRODUCTDEFINITIONSHAPE($,$,(#611)); +#613= IFCREINFORCINGBAR('1irBeCCUf82wdGg7qTPCbW',#56,$,$,$,#615,#612,$,$,$,$,$,$,$); +#614= IFCAXIS2PLACEMENT3D(#2,$,$); +#615= IFCLOCALPLACEMENT($,#614); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/reinforcing-stirrup.ifc b/files/ifc_files/reinforcing-stirrup.ifc new file mode 100644 index 000000000..a999255b0 --- /dev/null +++ b/files/ifc_files/reinforcing-stirrup.ifc @@ -0,0 +1,66 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [NotAssigned]'),'2;1'); +FILE_NAME( +/* name */ 'reinforcing-stirrup.ifc', +/* time_stamp */ '2016-02-04T08:47:55', +/* author */ ('redacted'), +/* organization */ ('redacted'), +/* preprocessor_version */ 'redacted', +/* originating_system */ 'redacted - redacted - 3.14159', +/* authorization */ 'None'); + +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* general entities required for all IFC data sets, defining the context for the exchange */ +#1= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.0001,#3,$); +#2= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#3= IFCAXIS2PLACEMENT3D(#2,$,$); +#4= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +#5= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#1,$,.MODEL_VIEW.,$); +/* defines the default building (as required as the minimum spatial element) */ +#50= IFCBUILDING('39t4Pu3nTC4ekXYRIHJB9W',#56,'IfcBuilding',$,$,$,$,$,$,$,$,$); +#51= IFCPERSONANDORGANIZATION(#52,#53,$); +#52= IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#53= IFCORGANIZATION($,'redacted',$,$,$); +#54= IFCAPPLICATION(#55,'redacted','redacted','redacted'); +#55= IFCORGANIZATION($,'redacted',$,$,$); +#56= IFCOWNERHISTORY(#51,#54,$,.ADDED.,1454575675,$,$,1454575675); +#57= IFCRELCONTAINEDINSPATIALSTRUCTURE('3Sa3dTJGn0H8TQIGiuGQd5',#56,'Building','Building Container for Elements',(#221),#50); +#58= IFCAXIS2PLACEMENT3D(#2,$,$); +#100= IFCPROJECT('0$WU4A9R19$vKWO$AdOnKA',#56,'IfcProject',$,$,$,$,(#1),#101); +#101= IFCUNITASSIGNMENT((#102,#103,#104)); +#102= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#103= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#104= IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.); +#105= IFCRELAGGREGATES('091a6ewbvCMQ2Vyiqspa7a',#56,'Project Container','Project Container for Buildings',#100,(#50)); +#200= IFCDOCUMENTREFERENCE($,'MyCodeISO3766','MyReinforcementCode',$,$); +#201= IFCRELASSOCIATESDOCUMENT('1R7R97$uLAAv4wci$KGwn8',#56,$,$,(#100),#200); +#202= IFCMATERIAL('ReinforcingSteel',$,$); +#203= IFCRELASSOCIATESMATERIAL('3gfVO40P5EfQyKZ_bF0R$6',#56,'MatAssoc','Material Associates',(#210),#202); +#204= IFCCARTESIANPOINTLIST3D(((-69.0,0.0,-122.0),(-69.0,0.0,-79.0),(-54.9411254969544,0.0,-45.0588745030457),(-21.0000000000001,0.0,-31.0),(21.0,0.0,-31.0),(54.9411254969543,0.0,-45.0588745030456),(69.0,0.0,-78.9999999999999),(69.0,0.00000000000000089,-321.0),(54.993978595716,1.21791490472038,-354.941125496954),(21.1804517666064,4.1582215855126,-369.0),(-20.6616529376114,7.79666547283599,-369.0),(-54.4751797667207,10.7369721536282,-354.941125496954),(-68.4812011710042,11.9548870583485,-320.999999999999),(-69.0,12.0,-79.0),(-54.9411254969544,12.0,-45.0588745030457),(-21.0000000000001,12.0,-31.0),(21.0,12.0,-31.0),(54.9411254969543,12.0,-45.0588745030456),(69.0,12.0,-78.9999999999999),(69.0,12.0,-122.0)),$); +#205= IFCINDEXEDPOLYCURVE(#204,(IFCLINEINDEX((1,2)),IFCARCINDEX((2,3,4)),IFCLINEINDEX((4,5)),IFCARCINDEX((5,6,7)),IFCLINEINDEX((7,8)),IFCARCINDEX((8,9,10)),IFCLINEINDEX((10,11)),IFCARCINDEX((11,12,13)),IFCLINEINDEX((13,14)),IFCARCINDEX((14,15,16)),IFCLINEINDEX((16,17)),IFCARCINDEX((17,18,19)),IFCLINEINDEX((19,20))),.F.); +#206= IFCSWEPTDISKSOLID(#205,6.0,$,$,$); +#207= IFCREPRESENTATIONMAP(#208,#209); +#208= IFCAXIS2PLACEMENT3D(#2,$,$); +#209= IFCSHAPEREPRESENTATION(#5,'Body','SolidModel',(#206)); +#210= IFCREINFORCINGBARTYPE('0jMRtfHYXE7u4s_CQ2uVE9',#56,'12 Diameter Ligature',$,$,$,(#207),$,$,.LIGATURE.,12.0,113.097335529233,1150.0,.TEXTURED.,$,$); +#211= IFCRELDEFINESBYTYPE('1iAfl2ERbFmwi7uniy1H7j',#56,$,$,(#221),#210); +#212= IFCRELDECLARES('2iXwonLML0_xb$7wRuG3vr',#56,$,$,#100,(#210)); +#213= IFCDIRECTION((1.0,0.0,0.0)); +#214= IFCDIRECTION((0.0,1.0,0.0)); +#215= IFCCARTESIANPOINT((0.0,0.0,0.0)); +#216= IFCCARTESIANTRANSFORMATIONOPERATOR3D(#213,#214,#215,1.0,#217); +#217= IFCDIRECTION((0.0,0.0,1.0)); +#218= IFCMAPPEDITEM(#207,#216); +#219= IFCSHAPEREPRESENTATION(#5,'Body','MappedRepresentation',(#218)); +#220= IFCPRODUCTDEFINITIONSHAPE($,$,(#219)); +#221= IFCREINFORCINGBAR('0WUveBtSTDbunNjDLsuRn$',#56,$,$,$,#223,#220,$,$,$,$,$,$,$); +#222= IFCAXIS2PLACEMENT3D(#2,$,$); +#223= IFCLOCALPLACEMENT($,#222); +ENDSEC; + +END-ISO-10303-21; + diff --git a/files/ifc_files/sectioned-solid-horizontal.ifc b/files/ifc_files/sectioned-solid-horizontal.ifc new file mode 100644 index 000000000..34d7a34c0 --- /dev/null +++ b/files/ifc_files/sectioned-solid-horizontal.ifc @@ -0,0 +1,190 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION (('ViewDefinition [Alignment-basedView]'), '2;1'); +FILE_NAME ('sectioned-solid-horizontal.ifc', '2021-05-31T10:35:59', ('redacted'), ('redacted'), 'redacted', 'redacted - redacted - 3.14159', ''); +FILE_SCHEMA (('IFC4X3_ADD2')); +ENDSEC; + +DATA; +#1 = IFCOWNERHISTORY(#2, #6, $, .NOCHANGE., $, $, $, 1622457359); +#2 = IFCPERSONANDORGANIZATION(#3, #4, $); +#3 = IFCPERSON($, 'redacted', 'redacted', $, $, $, $, $); +#4 = IFCORGANIZATION($, 'redacted', $, $, $); +#5 = IFCORGANIZATION($, 'redacted', $, $, $); +#6 = IFCAPPLICATION(#5, 'redacted', 'redacted', 'redacted'); +#7 = IFCPROJECT('0A_yMRoUvBdBdFGHn1GH7s', #1, 'Test IFC Project', $, $, $, $, (#13), #8); +#8 = IFCUNITASSIGNMENT((#9, #10, #11, #12)); +#9 = IFCSIUNIT(*, .LENGTHUNIT., $, .METRE.); +#10 = IFCSIUNIT(*, .AREAUNIT., $, .SQUARE_METRE.); +#11 = IFCSIUNIT(*, .VOLUMEUNIT., $, .CUBIC_METRE.); +#12 = IFCSIUNIT(*, .PLANEANGLEUNIT., $, .RADIAN.); +#13 = IFCGEOMETRICREPRESENTATIONCONTEXT($, 'Model', 3, 1.E-4, #14, #16); +#14 = IFCAXIS2PLACEMENT3D(#15, $, $); +#15 = IFCCARTESIANPOINT((0., 0., 0.)); +#16 = IFCDIRECTION((0., 1.)); +#17 = IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body', 'Model', *, *, *, *, #13, $, .MODEL_VIEW., $); +#18 = IFCARBITRARYCLOSEDPROFILEDEF(.AREA., 'Simple Profile', #19); +#19 = IFCINDEXEDPOLYCURVE(#20, (IFCLINEINDEX((1, 2)), IFCLINEINDEX((2, 3)), IFCLINEINDEX((3, 4)), IFCLINEINDEX((4, 1))), $); +#20 = IFCCARTESIANPOINTLIST2D(((-4., 0.), (-5., -1.), (5., -1.), (4., 0.)), $); +#24 = IFCSITE('35wH0d2VDFze34cZziHyWH', #1, 'Default Site', $, $, #25, $, $, .ELEMENT., (0, 0, 0), (0, 0, 0), 0., $, $); +#25 = IFCLOCALPLACEMENT($, #28); +#26 = IFCDIRECTION((0., 0., 1.)); +#27 = IFCDIRECTION((1., 0., 0.)); +#28 = IFCAXIS2PLACEMENT3D(#15, #26, #27); +#29 = IFCALIGNMENT('0qI6nq6055HgCBPMUsw303', #1, 'Test Alignment', $, $, #30, #104, $); +#30 = IFCLOCALPLACEMENT(#25, #33); +#31 = IFCDIRECTION((0., 0., 1.)); +#32 = IFCDIRECTION((1., 0., 0.)); +#33 = IFCAXIS2PLACEMENT3D(#15, #31, #32); +#34 = IFCALIGNMENTHORIZONTAL('2a51YNKc9CIA9mX6PHta0R', #1, $, $, $, $, $); +#35 = IFCALIGNMENTSEGMENT('2VLcO34uj1ThLg3tnDQSDu', #1, $, $, $, #1501, #1601, #36); +#36 = IFCALIGNMENTHORIZONTALSEGMENT($, $, #37, 0., 0., 0., 400., $, .LINE.); +#37 = IFCCARTESIANPOINT((0., 0.)); +#38 = IFCALIGNMENTSEGMENT('23in7RdgnF5etPmWAUg6Un', #1, $, $, $, #1501, #1611, #39); +#39 = IFCALIGNMENTHORIZONTALSEGMENT($, $, #40, 0., 0., -500.000000000002, 150., $, .CLOTHOID.); +#40 = IFCCARTESIANPOINT((400., 0.)); +#41 = IFCALIGNMENTSEGMENT('2f96e3LmrECxEoe22s5Xpd', #1, $, $, $, #1501, #1621, #42); +#42 = IFCALIGNMENTHORIZONTALSEGMENT($, $, #43, 6.13318530717958, -500.000000000002, -500.000000000002, 400., $, .CIRCULARARC.); +#43 = IFCCARTESIANPOINT((549.662851380011, -7.48795505445)); +#44 = IFCRELNESTS('3A9jueO1j2z8C76N1IFh7f', #1, 'Linear Element Nesting', $, #34, (#35, #38, #41, #1101)); +#45 = IFCALIGNMENTVERTICAL('3SOdKle3DEtuUG6EbrmQpy', #1, $, $, $, $, $); +#46 = IFCALIGNMENTSEGMENT('16nyAX_5956AZO3WakpmwA', #1, $, $, $, #2501, #2601, #47); +#47 = IFCALIGNMENTVERTICALSEGMENT($, $, 0., 449.999993741124, 150., -9.99999999995544E-4, -9.99999999995544E-4, $, .CONSTANTGRADIENT.); +#48 = IFCALIGNMENTSEGMENT('3Zuiuqd_1EPuTb4o9SuLse', #1, $, $, $, #2501, #2611, #49); +#49 = IFCALIGNMENTVERTICALSEGMENT($, $, 449.999993741124, 100.000006258876, 149.550000006261, -9.99999999995544E-4, 4.44444444449813E-4, 69230.7996321627, .CIRCULARARC.); +#50 = IFCALIGNMENTSEGMENT('1ZSIbG7kLDTxDPZKyT21HX', #1, $, $, $, #2501, #2621, #51); +#51 = IFCALIGNMENTVERTICALSEGMENT($, $, 550., 400., 149.522222225005, 4.44444444449813E-4, 4.44444444449813E-4, $, .CONSTANTGRADIENT.); +#52 = IFCRELNESTS('0f0eGFsknBWxOW9inF_FLN', #1, 'Linear Element Nesting', $, #45, (#46, #48, #50, #2001)); +#53 = IFCRELNESTS('0Ejb6tfov7UebksdwCEIZR', #1, 'Alignment Nesting', $, #29, (#34, #45)); +#54 = IFCCOMPOSITECURVE((#55, #63, #71, #1201), .U.); +#55 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #58, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(400.), #59); +#56 = IFCCARTESIANPOINT((0., 0.)); +#57 = IFCDIRECTION((1., 0.)); +#58 = IFCAXIS2PLACEMENT2D(#56, #57); +#59 = IFCLINE(#60, #61); +#60 = IFCCARTESIANPOINT((0., 0.)); +#61 = IFCVECTOR(#62, 1.); +#62 = IFCDIRECTION((1., 0.)); +#63 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #66, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(150.), #67); +#64 = IFCCARTESIANPOINT((400., 0.)); +#65 = IFCDIRECTION((1., 0.)); +#66 = IFCAXIS2PLACEMENT2D(#64, #65); +#67 = IFCCLOTHOID(#70, -273.861278752584); +#68 = IFCCARTESIANPOINT((0., 0.)); +#69 = IFCDIRECTION((1., 0.)); +#70 = IFCAXIS2PLACEMENT2D(#68, #69); +#71 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #74, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(-400.), #75); +#72 = IFCCARTESIANPOINT((549.662851380011, -7.48795505445)); +#73 = IFCDIRECTION((9.88771077936042E-1, -1.49438132473604E-1)); +#74 = IFCAXIS2PLACEMENT2D(#72, #73); +#75 = IFCCIRCLE(#78, 500.000000000002); +#76 = IFCCARTESIANPOINT((0., 0.)); +#77 = IFCDIRECTION((1., 0.)); +#78 = IFCAXIS2PLACEMENT2D(#76, #77); +#79 = IFCGRADIENTCURVE((#80, #88, #96, #2101), .U., #54, $); +#80 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #83, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(450.000218741065), #84); +#81 = IFCCARTESIANPOINT((0., 150.)); +#82 = IFCDIRECTION((9.99999500000375E-1, -9.99999499995919E-4)); +#83 = IFCAXIS2PLACEMENT2D(#81, #82); +#84 = IFCLINE(#85, #86); +#85 = IFCCARTESIANPOINT((0., 0.)); +#86 = IFCVECTOR(#87, 1.); +#87 = IFCDIRECTION((1., 0.)); +#88 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #91, IFCLENGTHMEASURE(326173.226503), IFCLENGTHMEASURE(100.00001881), #92); +#89 = IFCCARTESIANPOINT((449.999993741124, 149.550000006261)); +#90 = IFCDIRECTION((9.99999500000375E-1, -9.99999499995919E-4)); +#91 = IFCAXIS2PLACEMENT2D(#89, #90); +#92 = IFCCIRCLE(#95, 69230.7996321627); +#93 = IFCCARTESIANPOINT((0., 0.)); +#94 = IFCDIRECTION((1., 0.)); +#95 = IFCAXIS2PLACEMENT2D(#93, #94); +#96 = IFCCURVESEGMENT(.CONTSAMEGRADIENTSAMECURVATURE., #99, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(400.000039506171), #100); +#97 = IFCCARTESIANPOINT((550., 149.522222225005)); +#98 = IFCDIRECTION((9.99999901234583E-1, 4.44444400554072E-4)); +#99 = IFCAXIS2PLACEMENT2D(#97, #98); +#100 = IFCLINE(#101, #102); +#101 = IFCCARTESIANPOINT((0., 0.)); +#102 = IFCVECTOR(#103, 1.); +#103 = IFCDIRECTION((1., 0.)); +#104 = IFCPRODUCTDEFINITIONSHAPE($, $, (#106, #1002)); +#105 = IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis', 'Model', *, *, *, *, #13, $, .MODEL_VIEW., $); +#106 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Curve3D', (#79)); +#107 = IFCBUILTELEMENT('3k7$iV7T92$R$xNE9oe5R8', #1, 'SimpleProfile', $, $, #108, #115, $); +#108 = IFCLOCALPLACEMENT(#25, #114); +#109 = IFCDIRECTION((0., 0., 1.)); +#110 = IFCDIRECTION((1., 0., 0.)); +#114 = IFCAXIS2PLACEMENT3D(#15, #109, #110); +#115 = IFCPRODUCTDEFINITIONSHAPE($, $, (#127)); +#116 = IFCSECTIONEDSOLIDHORIZONTAL(#79, (#18, #18), (#117, #119)); +#117 = IFCAXIS2PLACEMENTLINEAR(#118, $, $); +#118 = IFCPOINTBYDISTANCEEXPRESSION(IFCLENGTHMEASURE(300.), $, $, $, #79); +#119 = IFCAXIS2PLACEMENTLINEAR(#120, $, $); +#120 = IFCPOINTBYDISTANCEEXPRESSION(IFCLENGTHMEASURE(600.), $, $, $, #79); +#127 = IFCSHAPEREPRESENTATION(#17, 'Body', 'AdvancedSweptSolid', (#116)); +#128 = IFCRELCONTAINEDINSPATIALSTRUCTURE('0de2EMoG5C3AvNHWz3ILSb', #1, 'Container', 'Container to Contained', (#107), #24); +#129 = IFCRELAGGREGATES('0PN7lKNInB_OsofZN8N0Nn', #1, 'Project Container', 'Project to Spatial Element', #7, (#24)); + +#1001 = IFCGEOMETRICREPRESENTATIONSUBCONTEXT('FootPrint', 'Model', *, *, *, *, #13, $, .MODEL_VIEW., $); +#1002 = IFCSHAPEREPRESENTATION(#1001, 'FootPrint', 'Curve2D', (#54)); + +#1101 = IFCALIGNMENTSEGMENT('1lqBKv7Xz1Hx2tmPCGtaIa', #1, $, $, $, #1501, #1631, #1102); +#1102 = IFCALIGNMENTHORIZONTALSEGMENT($, $, #1103, -0.95, 0., 0., 0., $, .LINE.); +#1103 = IFCCARTESIANPOINT((881.65153753789, -211.03194929054)); + +#1201 = IFCCURVESEGMENT(.DISCONTINUOUS., #1204, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(0.), #1205); +#1202 = IFCCARTESIANPOINT((0881.65153753789, -211.03194929054)); +#1203 = IFCDIRECTION((0.58168308946, -0.81341550478)); +#1204 = IFCAXIS2PLACEMENT2D(#1202, #1203); +#1205 = IFCLINE(#1206, #1207); +#1206 = IFCCARTESIANPOINT((0., 0.)); +#1207 = IFCVECTOR(#1208, 1.); +#1208 = IFCDIRECTION((1., 0.)); + +#1501 = IFCLOCALPLACEMENT($, #1502); +#1502 = IFCAXIS2PLACEMENT3D(#1503, $, $); +#1503 = IFCCARTESIANPOINT((0., 0., 0.)); + +#1601 = IFCPRODUCTDEFINITIONSHAPE($, $, (#1602)); +#1602 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#55)); +#1611 = IFCPRODUCTDEFINITIONSHAPE($, $, (#1612)); +#1612 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#63)); +#1621 = IFCPRODUCTDEFINITIONSHAPE($, $, (#1622)); +#1622 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#71)); +#1631 = IFCPRODUCTDEFINITIONSHAPE($, $, (#1632)); +#1632 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#1201)); + +#2001 = IFCALIGNMENTSEGMENT('39fS3hA8n5YOBvYphrBOIP', #1, $, $, $, #2501, #2631, #2002); +#2002 = IFCALIGNMENTVERTICALSEGMENT($, $, 950., 0., 149.7, 4.44444444449813E-4, 4.44444444449813E-4, $, .CONSTANTGRADIENT.); + +#2101 = IFCCURVESEGMENT(.DISCONTINUOUS., #2104, IFCLENGTHMEASURE(0.), IFCLENGTHMEASURE(0.), #2105); +#2102 = IFCCARTESIANPOINT((950., 149.7)); +#2103 = IFCDIRECTION((9.99999901234583E-1, 4.44444400554072E-4)); +#2104 = IFCAXIS2PLACEMENT2D(#2102, #2103); +#2105 = IFCLINE(#85, #86); + +#2501 = IFCLOCALPLACEMENT($, #2502); +#2502 = IFCAXIS2PLACEMENT3D(#2503, $, $); +#2503 = IFCCARTESIANPOINT((0., 0., 0.)); + +#2601 = IFCPRODUCTDEFINITIONSHAPE($, $, (#2602)); +#2602 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#80)); +#2611 = IFCPRODUCTDEFINITIONSHAPE($, $, (#2612)); +#2612 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#88)); +#2621 = IFCPRODUCTDEFINITIONSHAPE($, $, (#2622)); +#2622 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#96)); +#2631 = IFCPRODUCTDEFINITIONSHAPE($, $, (#2632)); +#2632 = IFCSHAPEREPRESENTATION(#105, 'Axis', 'Segment', (#2101)); + +#3000 = IFCRELAGGREGATES('0mRn50inT2pf1XqujacVu9', #1, 'Project Container 2', 'Project to Alignment', #7, (#29)); + +#3001 = IFCPOINTBYDISTANCEEXPRESSION(IFCLENGTHMEASURE(0.), $, $, $, #79); +#3002 = IFCAXIS2PLACEMENTLINEAR(#3001, $, $); +#3003 = IFCLINEARPLACEMENT($, #3002, #3007); +#3004 = IFCCARTESIANPOINT((0., 0., 150.)); +#3005 = IFCDIRECTION((0.000999999499995919, 0., 0.999999500000375)); +#3006 = IFCDIRECTION((0.999999500000375, 0., -0.000999999499995919)); +#3007 = IFCAXIS2PLACEMENT3D(#3004, #3005, #3006); +#3009 = IFCREFERENT('2GONnSxzfBfeoV9ClePg3I', #1, $, $, $, #3003, $, .STATION.); +#3010 = IFCRELNESTS('3YnMXSc7H3m9tXX4c2K11B', #1, $, $, #29, (#3009)); +ENDSEC; +END-ISO-10303-21; diff --git a/files/ifc_files/segmented-reference-curve.ifc b/files/ifc_files/segmented-reference-curve.ifc new file mode 100644 index 000000000..a6ce514ec --- /dev/null +++ b/files/ifc_files/segmented-reference-curve.ifc @@ -0,0 +1,139 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [Alignment-basedView]'),'2;1'); +FILE_NAME('segmented-reference-curve.ifc','2024-11-12T10:00:00',(''),(''),'redacted','redacted - redacted - 3.14159',''); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCPROJECT('1FNFy8AJeHwwz7wDZHIYIu',#3,'redacted','redacted',$,$,$,(#17),#9); +#2=IFCAPPLICATION(#5,'redacted','redacted','redacted'); +#3=IFCOWNERHISTORY(#6,#2,$,.ADDED.,1320688800,$,$,1320688800); +#4=IFCPERSON('redacted','redacted',$,$,$,$,$,$); +#5=IFCORGANIZATION('redacted','redacted',$,$,$); +#6=IFCPERSONANDORGANIZATION(#4,#5,$); +#7=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#8=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#9=IFCUNITASSIGNMENT((#7,#8)); +#10=IFCCARTESIANPOINT((0.,0.,0.)); +#11=IFCDIRECTION((0.,0.,1.)); +#12=IFCDIRECTION((1.,0.,0.)); +#13=IFCAXIS2PLACEMENT3D(#10,#11,#12); +#14=IFCLOCALPLACEMENT($,#13); +#15=IFCRAILWAY('1FNFy9AJeHwuVmwDZHIYIu',#3,$,$,$,#14,$,$,$,$); +#16=IFCDIRECTION((0.,1.)); +#17=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#13,#16); +#18=IFCRELAGGREGATES('1FNFyAAJeHwu6TwDZHIYIu',#3,$,$,#1,(#15,#20)); +#20=IFCALIGNMENT('1FNFyCAJeHwxedwDZHIYIu',#3,$,$,$,#137,#140,$); +#21=IFCALIGNMENTHORIZONTAL('1FNFyDAJeHwv87wDZHIYIu',$,$,$,$,#86,$); +#23=IFCRELNESTS('3BJTAQrjCHwvVKbERtTLTf',$,$,$,#20,(#21,#41,#61,#161)); +#28=IFCCARTESIANPOINT((0.,0.)); +#29=IFCALIGNMENTHORIZONTALSEGMENT($,$,#28,0.,300.,1000.,100.,$,.COSINECURVE.); +#30=IFCALIGNMENTSEGMENT('1FNFyHAJeHwuDtwDZHIYIu',#3,$,$,$,#67,#70,#29); +#34=IFCRELNESTS('1FNFyHAJeHwuDtwDZHIYIj',$,$,$,#21,(#30,#145)); +#41=IFCALIGNMENTVERTICAL('1FNFyDAJeHwv87wDZHIYI1',$,$,$,$,#109,$); +#42=IFCALIGNMENTSEGMENT('1FNFyHAJeHwuDtwDZHIYI2',#3,$,$,$,#91,#94,#44); +#43=IFCRELNESTS('3CGecNrjCHwxOSbERtTLTf',$,$,$,#41,(#42,#147)); +#44=IFCALIGNMENTVERTICALSEGMENT($,$,0.,100.,0.,0.,0.,$,.CONSTANTGRADIENT.); +#61=IFCALIGNMENTCANT('1FNFyDAJeHwv87wDZHIYI7',$,$,$,$,#134,$,1.5); +#62=IFCALIGNMENTSEGMENT('1FNFyHAJeHwuDtwDZHIYI3',#3,$,$,$,#114,#117,#64); +#63=IFCRELNESTS('2CGecOrjCHww8dbERtTLTf',$,$,$,#61,(#62,#149)); +#64=IFCALIGNMENTCANTSEGMENT($,$,0.,100.,0.,0.,0.16,0.,.COSINECURVE.); +#65=IFCCOMPOSITECURVE((#66,#78),.F.); +#66=IFCCURVESEGMENT(.CONTINUOUS.,#72,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(100.),#75); +#67=IFCLOCALPLACEMENT($,#68); +#68=IFCAXIS2PLACEMENT3D(#69,$,$); +#69=IFCCARTESIANPOINT((0.,0.,0.)); +#70=IFCPRODUCTDEFINITIONSHAPE($,$,(#71)); +#71=IFCSHAPEREPRESENTATION(#17,'Axis','Segment',(#66)); +#72=IFCAXIS2PLACEMENT2D(#73,#74); +#73=IFCCARTESIANPOINT((0.,0.)); +#74=IFCDIRECTION((1.,0.)); +#75=IFCCOSINESPIRAL(#76,857.142857142857,461.538461538462); +#76=IFCAXIS2PLACEMENT2D(#77,$); +#77=IFCCARTESIANPOINT((0.,0.)); +#78=IFCCURVESEGMENT(.DISCONTINUOUS.,#79,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(0.),#82); +#79=IFCAXIS2PLACEMENT2D(#80,#81); +#80=IFCCARTESIANPOINT((98.9298874325988,13.1346246945757)); +#81=IFCDIRECTION((1.,0.)); +#82=IFCLINE(#83,#84); +#83=IFCCARTESIANPOINT((0.,0.)); +#84=IFCVECTOR(#85,1.); +#85=IFCDIRECTION((1.,0.)); +#86=IFCLOCALPLACEMENT($,#87); +#87=IFCAXIS2PLACEMENT3D(#88,$,$); +#88=IFCCARTESIANPOINT((0.,0.,0.)); +#89=IFCGRADIENTCURVE((#90,#153),.F.,#65,#106); +#90=IFCCURVESEGMENT(.CONTINUOUS.,#96,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(100.),#99); +#91=IFCLOCALPLACEMENT($,#92); +#92=IFCAXIS2PLACEMENT3D(#93,$,$); +#93=IFCCARTESIANPOINT((0.,0.,0.)); +#94=IFCPRODUCTDEFINITIONSHAPE($,$,(#95)); +#95=IFCSHAPEREPRESENTATION(#17,'Axis','Segment',(#90)); +#96=IFCAXIS2PLACEMENT2D(#97,#98); +#97=IFCCARTESIANPOINT((0.,0.)); +#98=IFCDIRECTION((1.,0.)); +#99=IFCLINE(#100,#101); +#100=IFCCARTESIANPOINT((0.,0.)); +#101=IFCVECTOR(#102,1.); +#102=IFCDIRECTION((1.,0.)); +#106=IFCAXIS2PLACEMENT2D(#107,#108); +#107=IFCCARTESIANPOINT((100.,0.)); +#108=IFCDIRECTION((1.,0.)); +#109=IFCLOCALPLACEMENT($,#110); +#110=IFCAXIS2PLACEMENT3D(#111,$,$); +#111=IFCCARTESIANPOINT((0.,0.,0.)); +#112=IFCSEGMENTEDREFERENCECURVE((#113,#157),.F.,#89,#130); +#113=IFCCURVESEGMENT(.CONTINUOUS.,#123,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(100.),#127); +#114=IFCLOCALPLACEMENT($,#115); +#115=IFCAXIS2PLACEMENT3D(#116,$,$); +#116=IFCCARTESIANPOINT((0.,0.,0.)); +#117=IFCPRODUCTDEFINITIONSHAPE($,$,(#118)); +#118=IFCSHAPEREPRESENTATION(#17,'Axis','Segment',(#113)); +#123=IFCAXIS2PLACEMENT3D(#124,#125,#126); +#124=IFCCARTESIANPOINT((0.,0.08,0.)); +#125=IFCDIRECTION((-0.,0.106064981392206,0.994359200551929)); +#126=IFCDIRECTION((1.,0.,0.)); +#127=IFCCOSINESPIRAL(#128,2500.,2500.); +#128=IFCAXIS2PLACEMENT2D(#129,$); +#129=IFCCARTESIANPOINT((0.,0.)); +#130=IFCAXIS2PLACEMENT3D(#131,#132,#133); +#131=IFCCARTESIANPOINT((100.,0.,0.)); +#132=IFCDIRECTION((0.,-0.,1.)); +#133=IFCDIRECTION((1.,0.,0.)); +#134=IFCLOCALPLACEMENT($,#135); +#135=IFCAXIS2PLACEMENT3D(#136,$,$); +#136=IFCCARTESIANPOINT((0.,0.,0.)); +#137=IFCLOCALPLACEMENT($,#138); +#138=IFCAXIS2PLACEMENT3D(#139,$,$); +#139=IFCCARTESIANPOINT((0.,0.,0.)); +#140=IFCPRODUCTDEFINITIONSHAPE($,$,(#141,#142)); +#141=IFCSHAPEREPRESENTATION(#17,'Axis','Curve3D',(#112)); +#142=IFCSHAPEREPRESENTATION(#17,'FootPrint','Curve2D',(#65)); +#143=IFCCARTESIANPOINT((98.929887264154,13.1346242263917)); +#144=IFCALIGNMENTHORIZONTALSEGMENT($,$,#143,0.216666666666666,0.,0.,0.,$,.LINE.); +#145=IFCALIGNMENTSEGMENT('0pkgAW6$nFUxI4Rz7nfXqD',$,'H2',$,$,$,$,#144); +#146=IFCALIGNMENTVERTICALSEGMENT($,$,100.,0.,0.,0.,0.,$,.CONSTANTGRADIENT.); +#147=IFCALIGNMENTSEGMENT('096DNdiXLAG8XiglQEXS6m',$,'H2',$,$,$,$,#146); +#148=IFCALIGNMENTCANTSEGMENT($,$,100.,0.,0.,0.,0.,0.,.CONSTANTCANT.); +#149=IFCALIGNMENTSEGMENT('309BB_YA56aeUKwbrzAQk4',$,'H2',$,$,$,$,#148); +#150=IFCCARTESIANPOINT((100.,0.)); +#151=IFCDIRECTION((1.,0.)); +#152=IFCAXIS2PLACEMENT2D(#150,#151); +#153=IFCCURVESEGMENT(.DISCONTINUOUS.,#152,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(0.),#99); +#154=IFCCARTESIANPOINT((100.,0.)); +#155=IFCDIRECTION((1.,0.)); +#156=IFCAXIS2PLACEMENT2D(#154,#155); +#157=IFCCURVESEGMENT(.DISCONTINUOUS.,#156,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(0.),#127); +#158=IFCPOINTBYDISTANCEEXPRESSION(IFCLENGTHMEASURE(0.),$,$,$,#65); +#159=IFCAXIS2PLACEMENTLINEAR(#158,$,$); +#160=IFCLINEARPLACEMENT($,#159,#164); +#161=IFCREFERENT('0OHVJQIVnCTBfgbzqOY_6p',$,'Start Station',$,$,#160,$,.STATION.); +#162=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body',$,*,*,*,*,#17,$,.MODEL_VIEW.,$); +#163=IFCCARTESIANPOINT((0.,0.,0.)); +#164=IFCAXIS2PLACEMENT3D(#163,#166,#165); +#165=IFCDIRECTION((1.,0.,0.)); +#166=IFCDIRECTION((0.,0.,1.)); +#167=IFCPROJECTEDCRS('EPSG:26973',$,$,$,$,$,$); +#168=IFCMAPCONVERSION(#17,#167,0.,0.,0.,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/files/ifc_files/surface-model.ifc b/files/ifc_files/surface-model.ifc new file mode 100644 index 000000000..de7de16a3 --- /dev/null +++ b/files/ifc_files/surface-model.ifc @@ -0,0 +1,118 @@ +ISO-10303-21; +HEADER; +/* NOTE a valid model view name has to be asserted, replacing 'NotAssigned' ----------------- */ +FILE_DESCRIPTION( + ( 'ViewDefinition [NotAssigned]' + ,'Comment [manual creation of example file]' + ) + ,'2;1'); +/* NOTE standard header information according to ISO 10303-21 ---------------------------------- */ +FILE_NAME( + 'surface-model.ifc', + '2011-11-07T18:00:00', + ('redacted'), + ('redacted'), + 'redacted', + 'redacted - redacted - 3.14159', + 'reference file created for the IFC4 specification'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; + +DATA; +/* --------------------------------------------------------------------------------------------- */ +/* general entities required for all IFC data sets, defining the context for the exchange ------ */ +#100= IFCPROJECT('0xScRe4drECQ4DMSqUjd6d',#110,'proxy with surface model',$,$,$,$,(#201),#301); + +/* single owner history sufficient if not otherwise required by the view definition ------------ */ +/* provides the person and application creating the data set, and the time it is created ------- */ +#110= IFCOWNERHISTORY(#111,#115,$,.ADDED.,1320688800,$,$,1320688800); +#111= IFCPERSONANDORGANIZATION(#112,#113,$); +#112= IFCPERSON($,'redacted','redacted',$,$,$,$,$); +#113= IFCORGANIZATION($,'redacted',$,$,$); +#115= IFCAPPLICATION(#113,'redacted','redacted','redacted'); + +/* each IFC data set containing geometry has to define a geometric representation context ------ */ +/* the attribute 'ContextType' has to be 'Model' for 3D model geometry ------------------------- */ +#201= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,#210,$); +/* the attribute 'ContextIdentifier' has to be 'Body' for the main 3D shape representation ----- */ +#202= IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#201,$,.MODEL_VIEW.,$); +#210= IFCAXIS2PLACEMENT3D(#901,$,$); + +/* each IFC data set containing geometry has to define at absolute minimum length and angle ---- */ +/* here length is milli metre as SI unit, and plane angle is 'degree' as non SI unit ----------- */ +#301= IFCUNITASSIGNMENT((#311,#312)); +#311= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#312= IFCCONVERSIONBASEDUNIT(#313,.PLANEANGLEUNIT.,'degree',#314); +#313= IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#314= IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453293),#315); +#315= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); + +/* each IFC data set containing elements in a building context has to include a building ------- */ +/* at absolute minimum (could have a site and stories as well) --------------------------------- */ +#500= IFCBUILDING('2FCZDorxHDT8NI01kdXi8P',$,'Test Building',$,$,#511,$,$,.ELEMENT.,$,$,$); +/* if the building is the uppermost spatial structure element it defines the absolut position -- */ +#511= IFCLOCALPLACEMENT($,#512); +/* no rotation - z and x axes set to '$' are therefore identical to "world coordinate system" -- */ +#512= IFCAXIS2PLACEMENT3D(#901,$,$); +/* if the building is the uppermost spatial structure element it is assigned to the project ---- */ +#519= IFCRELAGGREGATES('2YBqaV_8L15eWJ9DA1sGmT',$,$,$,#100,(#500)); + +/* shared coordinates - it is permissible to share common instances to reduce file size -------- */ +#901= IFCCARTESIANPOINT((0.,0.,0.)); +#902= IFCDIRECTION((1.,0.,0.)); +#903= IFCDIRECTION((0.,1.,0.)); +#904= IFCDIRECTION((0.,0.,1.)); +#905= IFCDIRECTION((-1.,0.,0.)); +#906= IFCDIRECTION((0.,-1.,0.)); +#907= IFCDIRECTION((0.,0.,-1.)); + +/* --------------------------------------------------------------------------------------------- */ +/* proxy element with surface model shape representation, assigned to the building ------------- */ +#1000= IFCBUILDINGELEMENTPROXY('1kTvXnbbzCWw8lcMd1dR4o',$,'P-1','sample proxy',$,#1001,#1010,$,$); +/* proxy element placement relative to the building -------------------------------------------- */ +#1001= IFCLOCALPLACEMENT(#511,#1002); +/* set local placement to 1 meter on x-axis, and 0 on y, and 0 on z axes ----------------------- */ +/* no rotation - z and x axes set to '$' are therefore identical to those of building ---------- */ +#1002= IFCAXIS2PLACEMENT3D(#1003,$,$); +#1003= IFCCARTESIANPOINT((1000.,0.,0.)); +/* proxy element shape representation ---------------------------------------------------------- */ +#1010= IFCPRODUCTDEFINITIONSHAPE($,$,(#1020)); +/* a single shape representation of type 'SurfaceModel' is included ---------------------------- */ +#1020= IFCSHAPEREPRESENTATION(#202,'Body','SurfaceModel',(#1021)); +/* face based surface representation ----------------------------------------------------------- */ +/* cube, 1m width, 1m depth, 2m height --------------------------------------------------------- */ +#1021= IFCFACEBASEDSURFACEMODEL ((#1022)); +#1022= IFCCONNECTEDFACESET ((#1110, #1120, #1130, #1140, #1150, #1160)); +#1110= IFCFACE((#1111)); +#1111= IFCFACEOUTERBOUND(#1112,.T.); +#1112= IFCPOLYLOOP((#1201,#1202,#1206,#1205)); +#1120= IFCFACE((#1121)); +#1121= IFCFACEOUTERBOUND(#1122,.T.); +#1122= IFCPOLYLOOP((#1206,#1202,#1203,#1207)); +#1130= IFCFACE((#1131)); +#1131= IFCFACEOUTERBOUND(#1132,.T.); +#1132= IFCPOLYLOOP((#1207,#1203,#1204,#1208)); +#1140= IFCFACE((#1141)); +#1141= IFCFACEOUTERBOUND(#1142,.T.); +#1142= IFCPOLYLOOP((#1208,#1204,#1201,#1205)); +#1150= IFCFACE((#1151)); +#1151= IFCFACEOUTERBOUND(#1152,.T.); +#1152= IFCPOLYLOOP((#1201,#1204,#1203,#1202)); +#1160= IFCFACE((#1161)); +#1161= IFCFACEOUTERBOUND(#1162,.T.); +#1162= IFCPOLYLOOP((#1206,#1207,#1208,#1205)); +/* shared vertices of the faceted boundary representation -------------------------------------- */ +#1201= IFCCARTESIANPOINT((-500.,-500.,0.)); +#1202= IFCCARTESIANPOINT((500.,-500.,0.)); +#1203= IFCCARTESIANPOINT((500.,500.,0.)); +#1204= IFCCARTESIANPOINT((-500.,500.,0.)); +#1205= IFCCARTESIANPOINT((-500.,-500.,2000.)); +#1206= IFCCARTESIANPOINT((500.,-500.,2000.)); +#1207= IFCCARTESIANPOINT((500.,500.,2000.)); +#1208= IFCCARTESIANPOINT((-500.,500.,2000.)); + +/* proxy element assigned to the building ------------------------------------------------------ */ +#10000=IFCRELCONTAINEDINSPATIALSTRUCTURE('2TnxZkTXT08eDuMuhUUFNy',$,'Physical model',$,(#1000),#500); + +ENDSEC; +END-ISO-10303-21; \ No newline at end of file diff --git a/prototypes/ngeom_compare/align_eval.py b/prototypes/ngeom_compare/align_eval.py new file mode 100644 index 000000000..b37fd42cb --- /dev/null +++ b/prototypes/ngeom_compare/align_eval.py @@ -0,0 +1,245 @@ +"""Python reference evaluator for the IFC4x3 alignment directrix (IfcGradientCurve). + +Analytic eval of the horizontal IfcCompositeCurve (line / clothoid / circular arc segments) + +the vertical gradient, composed into a sampled 3D directrix with per-station frames. Validated +against the ifcopenshell.geom directrix oracle (align_oracle.py -> directrix_oracle.npy). + +This is the SPEC for the adacpp C++ port (ng:: analytic eval). No OCC. + +Run: pixi run -e tests-adacpp python prototypes/ngeom_compare/align_eval.py +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np + +SCRATCH = "/tmp/claude-1000/-home-kristoffer-code-dap/d103bfc1-7e2e-4f7e-ba7c-8e213d35e3df/scratchpad" + + +def fresnel(t): + """Normalized Fresnel integrals S(t)=int_0^t sin(pi u^2/2) du, C(t)=int_0^t cos(...). + + Power-series sum (converges fast for |t| <~ 2; this fixture's clothoid has t_max ~ 0.31). + The adacpp C++ port should swap in a rational/auxiliary-function Fresnel (Boersma) for large + arguments; documented as such in the spec. + """ + # C(t) = sum_n (-1)^n (pi/2)^{2n} t^{4n+1} / ((2n)! (4n+1)) + # S(t) = sum_n (-1)^n (pi/2)^{2n+1} t^{4n+3} / ((2n+1)! (4n+3)) + hp = math.pi / 2.0 + C = 0.0 + S = 0.0 + # C terms + term = t + n = 0 + while True: + c = term / (4 * n + 1) + C += c + # advance term: multiply by -(pi/2)^2 t^4 / ((2n+1)(2n+2)) + nt = term * (-(hp * hp) * t**4) / ((2 * n + 1) * (2 * n + 2)) + if abs(c) < 1e-18 and n > 2: + break + term = nt + n += 1 + if n > 200: + break + # S terms + term = hp * t**3 + n = 0 + while True: + s = term / (4 * n + 3) + S += s + nt = term * (-(hp * hp) * t**4) / ((2 * n + 2) * (2 * n + 3)) + if abs(s) < 1e-18 and n > 2: + break + term = nt + n += 1 + if n > 200: + break + return S, C + + +def _rot2d(from_dir, to_dir): + """2x2 rotation taking unit vector from_dir -> unit vector to_dir.""" + fx, fy = from_dir + tx, ty = to_dir + # angle of to minus angle of from + c = fx * tx + fy * ty # cos + s = fx * ty - fy * tx # sin (cross) + return np.array([[c, -s], [s, c]]) + + +# ---- parent-curve local eval: param p = arc length -> (point2d, unit tangent2d) ---- + + +def _line_eval(p): + return np.array([p, 0.0]), np.array([1.0, 0.0]) + + +def _circle_eval(p, radius): + # IfcCircle in its placement frame: center at origin, start at (R,0), CCW. + # arc length p -> angle th = p / R + th = p / radius + pt = np.array([radius * math.cos(th), radius * math.sin(th)]) + tg = np.array([-math.sin(th), math.cos(th)]) # d/dp, already unit + return pt, tg + + +def _clothoid_eval(p, A): + # arc length p along the spiral. x = |A|sqrt(pi) C(t), y = sign(A) |A|sqrt(pi) S(t), + # t = p / (|A| sqrt(pi)). scipy.fresnel returns (S, C). + aa = abs(A) + scale = aa * math.sqrt(math.pi) + if scale == 0.0: + return np.array([p, 0.0]), np.array([1.0, 0.0]) + t = p / scale + S, C = fresnel(t) + pt = np.array([scale * C, math.copysign(1.0, A) * scale * S]) + # tangent: dx/dp = cos(pi t^2 /2)*... derivative of C is cos(pi t^2/2); chain dt/dp = 1/scale + ph = math.pi * t * t / 2.0 + tg = np.array([math.cos(ph), math.copysign(1.0, A) * math.sin(ph)]) + n = np.hypot(*tg) + return pt, tg / n + + +@dataclass +class Seg: + kind: str # 'line' | 'circle' | 'clothoid' + origin: np.ndarray # placement origin (2d) + refdir: np.ndarray # placement ref direction (2d, unit) + start: float # SegmentStart (arc length) + length: float # SegmentLength (signed) + radius: float = 0.0 + A: float = 0.0 + + def _parent(self, p): + if self.kind == "line": + return _line_eval(p) + if self.kind == "circle": + return _circle_eval(p, self.radius) + if self.kind == "clothoid": + return _clothoid_eval(p, self.A) + raise ValueError(self.kind) + + def eval(self, local_len): + """local_len in [0, abs(length)] -> global (point2d, unit tangent2d). + + Maps the parent's (point,tangent) at SegmentStart to (origin, refdir) by a rigid 2D + transform; advances arc length in the sign of SegmentLength. + """ + sgn = 1.0 if self.length >= 0 else -1.0 + p = self.start + sgn * local_len + P, T = self._parent(p) + P0, T0 = self._parent(self.start) + if sgn < 0: + T = -T # traversal direction reverses + T0 = -T0 + R = _rot2d(T0, self.refdir) + gp = self.origin + R @ (P - P0) + gt = R @ T + return gp, gt / np.hypot(*gt) + + +# ---- the fixture's two composite curves, hand-transcribed from the IFC ---- + + +def horizontal_segs(): + return [ + Seg("line", np.array([0.0, 0.0]), np.array([1.0, 0.0]), 0.0, 400.0), + Seg("clothoid", np.array([400.0, 0.0]), np.array([1.0, 0.0]), 0.0, 150.0, A=-273.861278752584), + Seg( + "circle", + np.array([549.662851380011, -7.48795505445]), + np.array([9.88771077936042e-1, -1.49438132473604e-1]), + 0.0, + -400.0, + radius=500.000000000002, + ), + # #1201 terminator (length 0) omitted + ] + + +def vertical_segs(): + return [ + Seg( + "line", np.array([0.0, 150.0]), np.array([9.99999500000375e-1, -9.99999499995919e-4]), 0.0, 450.000218741065 + ), + Seg( + "circle", + np.array([449.999993741124, 149.550000006261]), + np.array([9.99999500000375e-1, -9.99999499995919e-4]), + 4.71138898071803, + 100.00001881, + radius=69230.7996321627, + ), + Seg( + "line", + np.array([550.0, 149.522222225005]), + np.array([9.99999901234583e-1, 4.44444400554072e-4]), + 0.0, + 400.000039506171, + ), + # #2101 terminator omitted + ] + + +def _sample_polyline(segs, n_per): + """Sample each segment -> stacked global 2D points (no dedup at joins).""" + pts = [] + for s in segs: + for i in range(n_per + 1): + ll = abs(s.length) * i / n_per + gp, _ = s.eval(ll) + pts.append(gp) + return np.array(pts) + + +def directrix_points(n_per=400): + """Compose horizontal (x,y)(s) with vertical z(s) -> sampled 3D directrix. + + The vertical gradient is a curve in (distance, height); we evaluate it, build z(distance) + by interpolation on its first coordinate, then lift the horizontal samples. + """ + hsegs = horizontal_segs() + # horizontal cumulative arc length at each sample + H = [] + s_acc = 0.0 + for seg in hsegs: + L = abs(seg.length) + for i in range(n_per + 1): + ll = L * i / n_per + gp, _ = seg.eval(ll) + H.append((s_acc + ll, gp[0], gp[1])) + s_acc += L + H = np.array(H) # (s, x, y) + + # vertical: sample -> (distance, height) + V = _sample_polyline(vertical_segs(), n_per) # columns (distance, height) + # build z(distance) via interp on sorted distance + order = np.argsort(V[:, 0]) + vd = V[order, 0] + vz = V[order, 1] + z = np.interp(H[:, 0], vd, vz) + return np.column_stack([H[:, 1], H[:, 2], z]) + + +def main(): + oracle = np.load(f"{SCRATCH}/directrix_oracle.npy") + pts = directrix_points(n_per=600) + print(f"eval pts: {len(pts)} oracle pts: {len(oracle)}") + print(f"eval first {pts[0]} last {pts[-1]}") + print(f"oracle first {oracle[0]} last {oracle[-1]}") + print(f"eval bbox min {pts.min(0)} max {pts.max(0)}") + print(f"oracle bbox min {oracle.min(0)} max {oracle.max(0)}") + + # nearest-neighbour max deviation: for each oracle pt, distance to nearest eval pt (brute) + d = np.empty(len(oracle)) + for i, o in enumerate(oracle): + d[i] = np.sqrt(((pts - o) ** 2).sum(1).min()) + print(f"oracle->eval nearest dist: max {d.max():.4f} mean {d.mean():.5f} p95 {np.percentile(d,95):.4f}") + + +if __name__ == "__main__": + main() diff --git a/prototypes/ngeom_compare/align_oracle.py b/prototypes/ngeom_compare/align_oracle.py new file mode 100644 index 000000000..459200968 --- /dev/null +++ b/prototypes/ngeom_compare/align_oracle.py @@ -0,0 +1,57 @@ +"""Oracle for the IFC4x3 alignment fixed-reference swept-area solid. + +Tessellate files/ifc_files/fixed-reference-swept-area-solid.ifc with ifcopenshell.geom +(USE_WORLD_COORDS) and report bbox / tri / vertex counts for the AdvancedSweptSolid body +(#113). This is the parity reference the native ngeom evaluator must match. + +Run: pixi run -e tests-adacpp python prototypes/ngeom_compare/align_oracle.py +""" + +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.geom +import numpy as np + +FIXTURE = "files/ifc_files/fixed-reference-swept-area-solid.ifc" + + +def main() -> None: + f = ifcopenshell.open(FIXTURE) + settings = ifcopenshell.geom.settings() + settings.set(settings.USE_WORLD_COORDS, True) + + # The swept solid is the body of the IfcBuiltElement #107 (SimpleProfile). + elem = f.by_id(107) + shape = ifcopenshell.geom.create_shape(settings, elem) + geo = shape.geometry + verts = np.asarray(geo.verts, dtype=float).reshape(-1, 3) + faces = np.asarray(geo.faces, dtype=int).reshape(-1, 3) + print("oracle body (#107 SimpleProfile)") + print(f" verts: {len(verts)} tris: {len(faces)}") + print(f" bbox min: {verts.min(axis=0)}") + print(f" bbox max: {verts.max(axis=0)}") + + # Sample the directrix (IfcGradientCurve #79) via the Curve3D 'Axis' rep so we have a + # ground-truth (x,y,z)(s) to validate the analytic evaluator against. + # (IfcShapeRepresentation #106 'Axis' Curve3D contains #79) + csettings = ifcopenshell.geom.settings() + csettings.set(csettings.USE_WORLD_COORDS, True) + try: + cshape = ifcopenshell.geom.create_shape(csettings, f.by_id(79)) + cverts = np.asarray(cshape.verts, dtype=float).reshape(-1, 3) + print("\noracle directrix (#79 IfcGradientCurve)") + print(f" sample pts: {len(cverts)}") + print(f" first: {cverts[0]}") + print(f" last: {cverts[-1]}") + print(f" bbox min: {cverts.min(axis=0)} max: {cverts.max(axis=0)}") + np.save( + "/tmp/claude-1000/-home-kristoffer-code-dap/d103bfc1-7e2e-4f7e-ba7c-8e213d35e3df/scratchpad/directrix_oracle.npy", + cverts, + ) + except Exception as e: # noqa: BLE001 + print(f"directrix sample failed: {e!r}") + + +if __name__ == "__main__": + main() diff --git a/prototypes/ngeom_compare/align_sweep.py b/prototypes/ngeom_compare/align_sweep.py new file mode 100644 index 000000000..50ece0e72 --- /dev/null +++ b/prototypes/ngeom_compare/align_sweep.py @@ -0,0 +1,74 @@ +"""Python reference sweep: profile-along-directrix shell mesh for the alignment fixture. + +Builds on align_eval.directrix_points by adding per-station fixed-reference frames and sweeping the +(baked IfcDerivedProfileDef) 2D profile -> a closed triangle shell. Validated vs the ifcopenshell +body bbox (align_oracle: x[-0.001,885.72] y[-213.95,5] z[148.515,150.856]). + +Spec for the adacpp C++ ng:: sweep. No OCC. + +Run: pixi run -e tests-adacpp python prototypes/ngeom_compare/align_sweep.py +""" + +from __future__ import annotations + +import align_eval as ae +import numpy as np + +# IfcDerivedProfileDef #114: parent #18 outline points, baked by operator #115 (Axis1=(0,-1)) -> +# (x,y) -> (y,-x). Hand-applied here (matches piece-1a derived_profile_def). +_PARENT = np.array([(-4.0, 0.0), (-5.0, -1.0), (5.0, -1.0), (4.0, 0.0)]) +PROFILE = np.column_stack([_PARENT[:, 1], -_PARENT[:, 0]]) # (y, -x) + +FIXED_REF = np.array([0.0, 0.0, 1.0]) + + +def _frames(pts): + """Per-station 3D tangent + fixed-reference in-plane axes (up, lateral).""" + T = np.gradient(pts, axis=0) + T /= np.linalg.norm(T, axis=1, keepdims=True) + F = FIXED_REF + lateral = np.cross(T, F) + lateral /= np.linalg.norm(lateral, axis=1, keepdims=True) + up = np.cross(lateral, T) + up /= np.linalg.norm(up, axis=1, keepdims=True) + return T, up, lateral + + +def sweep(n_per=600): + pts = ae.directrix_points(n_per=n_per) + T, up, lateral = _frames(pts) + M = len(PROFILE) + N = len(pts) + # rings[i] = N stations x M profile verts. profile-x -> up, profile-y -> lateral. + rings = ( + pts[:, None, :] + PROFILE[None, :, 0, None] * up[:, None, :] + PROFILE[None, :, 1, None] * lateral[:, None, :] + ) # (N, M, 3) + verts = rings.reshape(-1, 3) + + faces = [] + for i in range(N - 1): + for j in range(M): + a = i * M + j + b = i * M + (j + 1) % M + c = (i + 1) * M + j + d = (i + 1) * M + (j + 1) % M + faces.append((a, b, d)) + faces.append((a, d, c)) + # end caps (profile is a convex-ish quad -> fan triangulation) + for j in range(1, M - 1): + faces.append((0, j, j + 1)) # start cap + base = (N - 1) * M + faces.append((base, base + j + 1, base + j)) # end cap + return verts, np.array(faces) + + +def main(): + verts, faces = sweep(n_per=600) + print(f"sweep verts {len(verts)} tris {len(faces)}") + print(f"bbox min {verts.min(0)}") + print(f"bbox max {verts.max(0)}") + print("oracle body min [-9.99e-04 -2.139e+02 1.485e+02] max [885.72 5. 150.856]") + + +if __name__ == "__main__": + main() diff --git a/prototypes/ngeom_compare/align_zdiff.py b/prototypes/ngeom_compare/align_zdiff.py new file mode 100644 index 000000000..33990da9a --- /dev/null +++ b/prototypes/ngeom_compare/align_zdiff.py @@ -0,0 +1,96 @@ +"""Diagnose the z divergence between our analytic directrix and ifcopenshell's IfcGradientCurve. + +Extracts the ORDERED 3D directrix polyline from ifcopenshell (#79) by walking geometry.edges, +computes cumulative xy-distance s along it, and compares z(s) against our analytic eval. Also +tessellates each gradient CurveSegment (#80/#88/#96) and base CurveSegment (#55/#63/#71) +individually to see how ifcopenshell places/orders them. + +Run: pixi run -e tests-adacpp python prototypes/ngeom_compare/align_zdiff.py +""" + +from __future__ import annotations + +import align_eval as ae +import ifcopenshell +import ifcopenshell.geom +import numpy as np + +FIXTURE = "files/ifc_files/fixed-reference-swept-area-solid.ifc" + + +def _settings(): + s = ifcopenshell.geom.settings() + s.set(s.USE_WORLD_COORDS, True) + return s + + +def _ordered_polyline(verts, edges): + """Walk an edge list (flat pairs) into an ordered vertex-index path from a degree-1 end.""" + from collections import defaultdict + + adj = defaultdict(list) + e = np.asarray(edges, dtype=int).reshape(-1, 2) + for a, b in e: + adj[a].append(b) + adj[b].append(a) + ends = [v for v, nb in adj.items() if len(nb) == 1] + start = ends[0] if ends else int(e[0, 0]) + path = [start] + prev = None + cur = start + while True: + nxts = [n for n in adj[cur] if n != prev] + if not nxts: + break + prev, cur = cur, nxts[0] + path.append(cur) + if cur == start or len(path) > len(verts) + 5: + break + return verts[np.array(path)] + + +def main(): + f = ifcopenshell.open(FIXTURE) + s = _settings() + + # ordered 3D directrix from #79 + sh = ifcopenshell.geom.create_shape(s, f.by_id(79)) + v = np.asarray(sh.verts, float).reshape(-1, 3) + ed = np.asarray(sh.edges, int) + poly = _ordered_polyline(v, ed) + # cumulative xy distance along the ordered polyline + dxy = np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(poly[:, :2], axis=0), axis=1))] + print(f"#79 ordered polyline: {len(poly)} pts, total xy-len {dxy[-1]:.3f}") + + # our analytic z at the same cumulative distance + z_ours = [] + V = ae._sample_polyline(ae.vertical_segs(), 2000) + order = np.argsort(V[:, 0]) + z_ours = np.interp(dxy, V[order, 0], V[order, 1]) + + print("\n s(xy) x y z_ifc z_ours dz") + for k in np.linspace(0, len(poly) - 1, 12).astype(int): + print( + f"{dxy[k]:7.1f} {poly[k,0]:8.1f} {poly[k,1]:8.1f} {poly[k,2]:8.3f} {z_ours[k]:8.3f} {poly[k,2]-z_ours[k]:+7.3f}" + ) + + # where does dz cross a threshold? find first index where |dz|>0.01 + dz = poly[:, 2] - z_ours + bad = np.where(np.abs(dz) > 0.01)[0] + if len(bad): + i = bad[0] + print( + f"\nfirst divergence at idx {i}: s_xy={dxy[i]:.2f} (x={poly[i,0]:.1f},y={poly[i,1]:.1f}) z_ifc={poly[i,2]:.3f} z_ours={z_ours[i]:.3f}" + ) + + # segment-3 horizontal starts at distance 550 (x~550), the circle. Print z_ifc vs the + # vertical-line z that SHOULD apply there (149.522 + 0.000444*(s-550)). + print("\nhorizontal-circle region (s>=550): z_ifc vs expected straight-grade 149.522+4.444e-4*(s-550)") + for k in range(len(poly)): + if dxy[k] >= 550 and k % max(1, len(poly) // 30) == 0: + exp = 149.522222225005 + 4.44444400554072e-4 * (dxy[k] - 550) + print(f" s={dxy[k]:7.1f} z_ifc={poly[k,2]:.4f} z_line={exp:.4f} d={poly[k,2]-exp:+.4f}") + + +if __name__ == "__main__": + main() diff --git a/src/ada/api/beams/geom_beams.py b/src/ada/api/beams/geom_beams.py index b84c735bf..d5c1128f0 100644 --- a/src/ada/api/beams/geom_beams.py +++ b/src/ada/api/beams/geom_beams.py @@ -228,6 +228,28 @@ def parametric_profile_to_arbitrary(area: geo_su.ProfileDef) -> geo_su.Arbitrary t_w=area.web_thickness, t_ftop=area.flange_thickness, ) + elif isinstance(area, geo_su.RectangleProfileDef): + # IFC semantics: the rectangle is centred on the profile position. + # adapy's RectangleProfileDef carries no Position (readers only see + # the default $), so an explicit centred outline is exact — no + # Section detour needed. + from ada.geom.curves import Edge, IndexedPolyCurve + from ada.geom.points import Point + + dx, dy = area.x_dim / 2.0, area.y_dim / 2.0 + corners = [ + Point(-dx, -dy), + Point(dx, -dy), + Point(dx, dy), + Point(-dx, dy), + ] + segments = [Edge(corners[i], corners[(i + 1) % 4]) for i in range(4)] + return geo_su.ArbitraryProfileDef( + area.profile_type, + IndexedPolyCurve(segments), + [], + profile_name=getattr(area, "profile_name", None), + ) else: raise NotImplementedError(f"Profile def {type(area).__name__} is not implemented") @@ -292,7 +314,11 @@ def profile_disconnected_to_face_geom(beam: Beam) -> Geometry: points = np.concatenate([p1, p2, p3, p4]).reshape(-1, 3) new_points = np.matmul(rotation_matrix, points.T).T + beam.n1.p poly_loop = ada.geom.curves.PolyLoop(polygon=[Point(*p) for p in new_points]) - connected_faces += [geo_su.ConnectedFaceSet([geo_su.FaceBound(bound=poly_loop, orientation=True)])] + # cfs_faces holds Faces (per IfcConnectedFaceSet), each carrying its FaceBound(s) — not a + # bare FaceBound. Wrapping keeps this consistent with the IFC face-set reader so the shared + # face-set OCC/NGEOM builders handle both. + loop_face = geo_su.Face(bounds=[geo_su.FaceBound(bound=poly_loop, orientation=True)]) + connected_faces += [geo_su.ConnectedFaceSet([loop_face])] geom = geo_su.FaceBasedSurfaceModel(connected_faces) return Geometry(beam.guid, geom, beam.color) diff --git a/src/ada/api/curves.py b/src/ada/api/curves.py index 954d95677..fe1821e68 100644 --- a/src/ada/api/curves.py +++ b/src/ada/api/curves.py @@ -451,6 +451,73 @@ def from_fem_shell(cls, points3d, tol=1e-3, parent=None) -> CurvePoly2d: self._nodes = [Node(p) for p in points3d_pts] return self + @classmethod + def from_fem_shells_batch(cls, pts: np.ndarray, parent=None, tol=1e-3) -> list[CurvePoly2d | None]: + """Vectorized :meth:`from_fem_shell` for ``m`` same-arity k-gons (m, k, 3). + + The per-element orientation/projection math runs once over arrays + (:func:`ada.core.vector_transforms.shell_orientations_bulk` — same + floating-point operation order and Decimal rounding as the scalar + chain); only the output objects (Point/LineSegment/Node) are built per + element. Rows the bulk math can't take (degenerate corners/edges) + return ``None`` — the caller runs those through ``from_fem_shell``. + """ + from ada.core.vector_transforms import shell_orientations_bulk + + pts = np.asarray(pts, dtype=float) + m, k, _ = pts.shape + ok, rot1, rotf = shell_orientations_bulk(pts) + origin = pts[:, 0] + + # 3D -> 2D via rot1 (transform_3x3: pos @ rot.T), batched — matmul runs + # the same (k,3)@(3,3) product per element as the scalar np.dot. + rel = pts - origin[:, None, :] + local = np.matmul(rel, np.transpose(rot1, (0, 2, 1)))[:, :, :2] + + # winding: keep first point + reverse the rest when is_clockwise is False. + # is_clockwise sums (x2-x1)(y2+y1) over consecutive pairs and closes with + # (x[-1]-x[0])(y[-1]+y[0]) — NOT the wrapped shoelace closing edge — + # replicated verbatim so the reorder decision matches bit for bit. + x, y = local[:, :, 0], local[:, :, 1] + psum = ((x[:, 1:] - x[:, :-1]) * (y[:, 1:] + y[:, :-1])).sum(axis=1) + psum = psum + (x[:, -1] - x[:, 0]) * (y[:, -1] + y[:, 0]) + ccw = psum < 0 # is_clockwise returns `not psum < 0` + reordered = local.copy() + reordered[:, 1:, :] = local[:, :0:-1, :] + wound = np.where(ccw[:, None, None], reordered, local) + + # back to 3D via the final frame (transform_3x3 inverse: pos @ rot) + origin + wound3 = np.concatenate([wound, np.zeros((m, k, 1))], axis=2) + back = np.matmul(wound3, rotf) + origin[:, None, :] + + out: list[CurvePoly2d | None] = [] + for i in range(m): + if not ok[i]: + out.append(None) + continue + points2d = [Point(wound[i, j, 0], wound[i, j, 1]) for j in range(k)] + points3d_pts = [Point(back[i, j]) for j in range(k)] + segs2d = [LineSegment(points2d[j - 1], points2d[j]) for j in range(k)] + segs3d = [LineSegment(points3d_pts[j - 1], points3d_pts[j]) for j in range(k)] + seg_global_points, seg_index = segments_to_indexed_lists(segs3d) + xf, yf, zf = Direction(rotf[i, 0]), Direction(rotf[i, 1]), Direction(rotf[i, 2]) + + self = cls.__new__(cls) + self._tol = tol + self._parent = parent + self._orientation = Placement.from_dirs_precomputed(origin[i], xf, yf, zf) + self._placement = Placement() + self._radiis = {} + self._points2d = points2d + self._points3d = points3d_pts + self._segments = segs2d + self._segments3d = segs3d + self._seg_global_points = seg_global_points + self._seg_index = seg_index + self._nodes = [Node(p) for p in points3d_pts] + out.append(self) + return out + def get_face_geom(self) -> ArbitraryProfileDef: outer_curve = self.curve_geom() return ArbitraryProfileDef(ProfileType.AREA, outer_curve, []) diff --git a/src/ada/api/primitives/base.py b/src/ada/api/primitives/base.py index e776bfde7..11ae4463c 100644 --- a/src/ada/api/primitives/base.py +++ b/src/ada/api/primitives/base.py @@ -20,6 +20,107 @@ from ada.cadit.ifc.store import IfcStore +def _bake_placement_into_geometry(geom_wrapper: Geometry, placement: Placement) -> Geometry | None: + """Return a COPY of ``geom_wrapper`` with ``placement`` folded into the underlying solid's + ``position``, so a placement-agnostic tessellator renders the shape in world coordinates. + + Only analytic solids (those carrying an ``Axis2Placement3D`` / ``Axis1Placement`` + ``position`` — Box, Sphere, Cylinder, Cone, Extruded/Revolved/Swept area solids) are + supported; anything else (face sets, B-reps) returns ``None`` so the caller keeps the + unplaced geometry (a follow-up). Uses the public ``transform_local_points_to_global`` / + ``transform_vector`` helpers, which encode the (now self-consistent) Placement rotation + convention — ``transform_local_points_to_global(p) == rot_matrix @ p + origin`` — so the + location, axis and ref_direction land on the same world frame the tessellator expects. + """ + import copy + + import numpy as np + + from ada import Direction + from ada.geom.placement import Axis1Placement, Axis2Placement3D + + abs_p = placement.get_absolute_placement(include_rotations=True) + + def _pt(p): + q = abs_p.transform_local_points_to_global(np.asarray([[float(p[0]), float(p[1]), float(p[2])]]))[0] + return Point(q[0], q[1], q[2]) + + def _vec(v): + return abs_p.transform_vector(np.asarray([float(v[0]), float(v[1]), float(v[2])])) + + solid = geom_wrapper.geometry + pos = getattr(solid, "position", None) + if isinstance(pos, (Axis2Placement3D, Axis1Placement)): + new_solid = copy.copy(solid) # shallow: only ``position`` is replaced + loc = _pt(pos.location) + axis = _vec(pos.axis) + if isinstance(pos, Axis2Placement3D): + ref = _vec(pos.ref_direction) + new_solid.position = Axis2Placement3D(loc, Direction(*axis), Direction(*ref)) + else: + new_solid.position = Axis1Placement(loc, Direction(*axis)) + return Geometry(geom_wrapper.id, new_solid, geom_wrapper.color, bool_operations=geom_wrapper.bool_operations) + + # Point-based / face-set geometries (no ``position``): transform their vertices. Covers + # tessellated & polygonal face sets and the face-set / surface-model hierarchy + # (FaceBasedSurfaceModel -> ConnectedFaceSet -> Face -> FaceBound -> PolyLoop). Anything with a + # non-poly-loop bound (EdgeLoop) or a carried surface returns None (unplaced — a follow-up). + placed = _bake_placement_into_facelike(solid, _pt) + if placed is not None: + return Geometry(geom_wrapper.id, placed, geom_wrapper.color, bool_operations=geom_wrapper.bool_operations) + return None + + +def _bake_placement_into_facelike(geom, pt): + """Return a COPY of a point-based/face-set geometry with every vertex mapped through ``pt``, or + None if the geometry carries structure we can't transform by moving vertices alone.""" + import ada.geom.curves as geo_cu + import ada.geom.surfaces as geo_su + + def loop(lp): + return geo_cu.PolyLoop([pt(p) for p in lp.polygon]) if isinstance(lp, geo_cu.PolyLoop) else None + + def face(f): + if type(f) is not geo_su.Face: # a FaceSurface carries a surface we'd also have to move + return None + bounds = [] + for b in f.bounds: + lp = loop(b.bound) + if lp is None: + return None + bounds.append(geo_su.FaceBound(lp, b.orientation)) + return geo_su.Face(bounds=bounds) + + def faces(fs): + out = [] + for f in fs: + nf = face(f) + if nf is None: + return None + out.append(nf) + return out + + if isinstance(geom, geo_su.TriangulatedFaceSet): + return geo_su.TriangulatedFaceSet([pt(p) for p in geom.coordinates], geom.normals, geom.indices) + if isinstance(geom, geo_su.PolygonalFaceSet): + return geo_su.PolygonalFaceSet([pt(p) for p in geom.coordinates], geom.faces, geom.closed) + if isinstance(geom, geo_su.FaceBasedSurfaceModel): + cfs = [] + for c in geom.fbsm_faces: + nf = faces(c.cfs_faces) + if nf is None: + return None + cfs.append(geo_su.ConnectedFaceSet(nf)) + return geo_su.FaceBasedSurfaceModel(cfs) + if isinstance(geom, geo_su.ConnectedFaceSet): + nf = faces(geom.cfs_faces) + return geo_su.ConnectedFaceSet(nf) if nf is not None else None + if isinstance(geom, (geo_su.ClosedShell, geo_su.OpenShell)): + nf = faces(geom.cfs_faces) + return type(geom)(nf) if nf is not None else None + return None + + class Shape(BackendGeom): IFC_CLASSES = ShapeTypes @@ -168,6 +269,15 @@ def solid_geom(self) -> Geometry: if self.geom is None: raise NotImplementedError(f"solid_geom() not implemented for {self.__class__.__name__}") + # Carry the Shape's colour onto its Geometry when the wrapper was built + # without one (SAT/STEP imports pass ``Geometry(i, geom)`` with no colour, + # so the tessellator — which reads ``geom.color`` — otherwise gets None and + # renders the body black). ``self.color`` defaults to light-gray, matching + # what Plate.solid_geom already injects. A colour the geom already carries + # (e.g. an IFC style) is left untouched. + if self.geom.color is None and self.color is not None: + self.geom.color = self.color + import ada.geom.solids as geo_so import ada.geom.surfaces as geo_su @@ -177,7 +287,12 @@ def solid_geom(self) -> Geometry: geo_su.AdvancedFace, geo_su.ClosedShell, geo_su.OpenShell, + geo_su.ConnectedFaceSet, # the native NGEOM reader's B-rep root form geo_su.ShellBasedSurfaceModel, + geo_su.FaceBasedSurfaceModel, + geo_su.TriangulatedFaceSet, # pre-tessellated (IfcTriangulatedFaceSet) — direct mesh path + geo_su.PolygonalFaceSet, # IfcPolygonalFaceSet — shared point list + n-gon faces + geo_so.FacetedBrep, geo_so.Box, geo_so.Sphere, geo_so.Cylinder, @@ -186,6 +301,7 @@ def solid_geom(self) -> Geometry: geo_so.ExtrudedAreaSolidTapered, geo_so.RevolvedAreaSolid, geo_so.FixedReferenceSweptAreaSolid, + geo_so.SweptDiskSolid, # pipes/rods (IfcSweptDiskSolid) — OCC + NGEOM builders exist ), ): @@ -197,6 +313,20 @@ def solid_geom(self) -> Geometry: self.geom.bool_operations = [ BooleanOperation(x.primitive.solid_geom(), x.bool_op) for x in self.booleans ] + + # Bake a non-identity placement into the geometry. The tessellator/exporters are + # placement-agnostic for generic Shapes (unlike Beam, whose straight_beam_to_geom + # bakes it in, and the Prim* subclasses, which override solid_geom) — so an IFC/ + # native shape read in LOCAL representation coords with its world transform held in + # ``self.placement`` would otherwise render unplaced (rotation dropped; translation + # only happened to work when it was already baked into the geom). Returns a COPY so + # repeat calls don't compound; ``self.geom``/``self.placement`` are never mutated. + # Only analytic (positioned) solids are handled today — face-set / B-rep placement + # baking is a follow-up (helper returns None → keep the unplaced geom). + if not self.placement.is_identity(): + placed = _bake_placement_into_geometry(self.geom, self.placement) + if placed is not None: + return placed return self.geom else: raise NotImplementedError(f"solid_geom() not implemented for {self.geom=}") diff --git a/src/ada/api/shapes/__init__.py b/src/ada/api/shapes/__init__.py new file mode 100644 index 000000000..72130502b --- /dev/null +++ b/src/ada/api/shapes/__init__.py @@ -0,0 +1,4 @@ +from .proxies import ShapeProxy +from .store import ShapeRecord, ShapeStore + +__all__ = ["ShapeProxy", "ShapeRecord", "ShapeStore"] diff --git a/src/ada/api/shapes/proxies.py b/src/ada/api/shapes/proxies.py new file mode 100644 index 000000000..59712cdbc --- /dev/null +++ b/src/ada/api/shapes/proxies.py @@ -0,0 +1,89 @@ +"""Lazy shape proxies over a :class:`~ada.api.shapes.store.ShapeStore`. + +``ShapeProxy`` subclasses ``Shape`` so every ``isinstance`` check, container and +export path keeps working, but it does not hold an ``ada.geom`` tree: ``.geom`` +hydrates from the store's compact blob on access (weakref-cached there), so a +7k-solid import costs blobs + small records instead of millions of resident +dataclass instances. Unlike the FEM ``NodeProxy`` (millions of rows, base init +skipped), shape counts are ~10k per model, so the proxy runs the normal +``Shape.__init__`` and keeps material/placement/guid/pickle behaviour intact. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ada.api.primitives.base import Shape +from ada.base.units import Units + +if TYPE_CHECKING: + from ada.geom.core import Geometry + + from .store import ShapeStore + + +class ShapeProxy(Shape): + def __init__(self, name, store: ShapeStore, index: int, **shape_kwargs): + super().__init__(name, geom=None, **shape_kwargs) + self._shape_store = store + self._store_index = int(index) + self._pinned_geom = None + + @property + def geom(self) -> Geometry: + """The hydrated geometry. Transient unless pinned: edits to the returned tree + are lost when the GC reclaims it — call :meth:`pin` (or assign ``.geom``) + before mutating.""" + if self._pinned_geom is not None: + return self._pinned_geom + return self._shape_store.geometry(self._store_index) + + @geom.setter + def geom(self, value: Geometry): + # Pin, never re-serialize: the NGEOM encoder silently skips unsupported + # kinds, so writing back into the store could lose geometry. + self._pinned_geom = value + + def pin(self) -> Geometry: + """Hydrate and hold a strong reference so subsequent mutation sticks.""" + if self._pinned_geom is None: + self._pinned_geom = self._shape_store.geometry(self._store_index) + return self._pinned_geom + + def ngeom_blob(self): + """The stored NGEOM buffer, for consumers that feed adacpp directly + (tessellation, stream export) without hydrating. ``None`` when the shape + was stored from Python-built geometry (pickle kind).""" + return self._shape_store.ngeom_blob(self._store_index) + + def is_bare_curve(self) -> bool: + """The stored geometry is a bare curve (wire body) — render as line geometry. + Answered from the record, no hydration.""" + return self._shape_store.record(self._store_index).curve + + @property + def units(self): + return self._units + + @units.setter + def units(self, value): + # Mirrors Shape.units but treats the proxy as always-having-geometry: the + # base setter keys on ``self._geom is not None``, which is always False + # here and would silently skip the scaling. + if isinstance(value, str): + value = Units.from_str(value) + if value != self._units: + from ada.occ.utils import transform_shape + + scale_factor = Units.get_scale_factor(self._units, value) + # transform_shape returns an OCC body; after a unit conversion the OCC + # cache is the source of truth, exactly as on the base class. + self._occ_cache = transform_shape(self.solid_occ(), scale_factor) + + if self.metadata.get("ifc_source") is True: + raise NotImplementedError() + + self._units = value + + def __repr__(self): + return f'{self.__class__.__name__}("{self.name}", store_index={self._store_index})' diff --git a/src/ada/api/shapes/store.py b/src/ada/api/shapes/store.py new file mode 100644 index 000000000..49afe13b6 --- /dev/null +++ b/src/ada/api/shapes/store.py @@ -0,0 +1,248 @@ +"""Buffer-backed storage for imported shape geometry. + +A large STEP/IFC import materialised as ``ada.geom`` object trees costs ~100x its +compact serialized form in resident memory (millions of small dataclass instances, +each with a ``__dict__``). ``ShapeStore`` keeps each solid as one compact blob and +hydrates the ``ada.geom.Geometry`` tree only when a consumer actually asks for it, +mirroring the FEM array-substrate design (``ada.api.mesh.store.MeshArrays``): flat +shared storage + transient proxies (``ada.api.shapes.proxies.ShapeProxy``), with a +``WeakValueDictionary`` cache so repeated access within a live scope is free and +memory returns to the blob floor when hydrated trees are dropped. + +Two blob kinds: + +- ``"ngeom"`` — an NGEOM buffer as produced by the adacpp native readers + (``StepNgeomStream``). Retained exactly as it arrives (no ``bytes()`` round-trip, + no arena append — the buffer crossing the C++ boundary is the stored object, so + the transfer stays zero-copy). These blobs double as the tessellation/export + fast path: adacpp consumes them directly, skipping hydrate + re-serialize. +- ``"pickle"`` — Python-built ``ada.geom`` geometry (the IFC reader, API shapes). + The NGEOM solid encoders lower parametric solids (Box -> extruded rectangle, + parametric profiles -> arbitrary outlines), so round-tripping Python-built + geometry through NGEOM would be lossy; pickle keeps profiles, placements and + ``bool_operations`` exact at a comparable byte size. + +The small per-shape metadata that must stay eagerly queryable (id, color, instance +transforms/paths) lives in ``ShapeRecord`` next to the blob, not inside it — the +same split the native stream readers already make (``StepRootMeta``). +""" + +from __future__ import annotations + +import pickle +import weakref +import zlib +from collections import OrderedDict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ada.geom.core import Geometry + +if TYPE_CHECKING: + import numpy as np + + from ada.geom.booleans import BooleanOperation + from ada.visit.colors import Color + +_NGEOM_MAGIC = b"ADANGEOM" + + +@dataclass(slots=True) +class ShapeRecord: + """Eager per-shape metadata — one small record per stored solid.""" + + gid: str + kind: str # "ngeom" | "pickle" + color: Color | None = None + transforms: list[np.ndarray] | None = None + instance_paths: list[tuple] | None = None + compressed: bool = False + # The stored geometry is a bare curve (SAT wire body / open wireframe round- + # tripped through IFC) — consumers render it as line geometry. Recorded at + # store time so the tessellator's curve sniff never has to hydrate. + curve: bool = False + + +class ShapeStore: + """Holds many shapes' geometry as compact blobs; hydrates on demand. + + One store per import (``from_step``/``from_ifc`` call). Proxies keep a strong + reference to the store, so a surviving proxy keeps the (compact) blobs alive; + hydrated ``Geometry`` trees are only weakly cached and are reclaimed by the GC + as soon as no consumer holds them. + """ + + __slots__ = ("_blobs", "_records", "_geom_cache", "_geom_lru", "hydration_cache_size", "compress", "__weakref__") + + def __init__(self, compress: bool = False, hydration_cache_size: int = 16): + self._blobs: list[object] = [] + self._records: list[ShapeRecord] = [] + # Mirrors MeshArrays._proxy_cache: same live object for repeated access, + # GC'd when the last outside reference drops. + self._geom_cache: weakref.WeakValueDictionary[int, Geometry] = weakref.WeakValueDictionary() + # Small strong LRU on top of the weak cache. Consumers touch ``.geom`` + # several times in one call (``solid_geom()`` alone reads it 3-5x) and each + # temporary dies between property evaluations, so a purely weak cache + # re-decodes the blob per access (~40% slower conversions on the audit). + # A bounded strong window keeps the hot shape alive without giving up the + # hydrate-all-then-drop memory floor (N x ~0.3 MB, evicted FIFO). + self._geom_lru: OrderedDict[int, Geometry] = OrderedDict() + self.hydration_cache_size = hydration_cache_size + self.compress = compress + + def __len__(self) -> int: + return len(self._records) + + # --- ingest ------------------------------------------------------------------------- + + def add_blob( + self, + blob, + *, + gid: str, + color: Color | None = None, + transforms: list[np.ndarray] | None = None, + instance_paths: list[tuple] | None = None, + ) -> int: + """Retain an NGEOM buffer (bytes/memoryview/uint8 ndarray) as-arrived. + + The buffer object itself is stored — no copy — unless ``compress`` is on + (compression inherently copies; that is the flag's accepted trade-off). + """ + head = bytes(memoryview(blob)[:8]) + if head != _NGEOM_MAGIC: + raise ValueError(f"not an NGEOM buffer (magic={head!r}) for {gid!r}") + compressed = False + if self.compress: + blob = zlib.compress(bytes(blob), 1) + compressed = True + self._blobs.append(blob) + self._records.append( + ShapeRecord( + gid=gid, + kind="ngeom", + color=color, + transforms=transforms, + instance_paths=instance_paths, + compressed=compressed, + ) + ) + return len(self._records) - 1 + + def add_geometry(self, geometry: Geometry) -> int: + """Store a Python-built ``Geometry`` losslessly (pickle kind) and drop the tree. + + The wrapper's metadata moves to the record; the pickled payload is the inner + geometry + ``bool_operations`` (which pickle round-trips exactly, including + half-space operands and parametric profiles the NGEOM codec would lower). + """ + from ada.geom.curves import CURVE_GEOM_TUPLE + + payload = pickle.dumps((geometry.geometry, geometry.bool_operations), protocol=pickle.HIGHEST_PROTOCOL) + compressed = False + if self.compress: + payload = zlib.compress(payload, 1) + compressed = True + self._blobs.append(payload) + self._records.append( + ShapeRecord( + gid=str(geometry.id), + kind="pickle", + color=geometry.color, + transforms=geometry.transforms, + instance_paths=geometry.instance_paths, + compressed=compressed, + curve=isinstance(geometry.geometry, CURVE_GEOM_TUPLE), + ) + ) + return len(self._records) - 1 + + # --- access ------------------------------------------------------------------------- + + def record(self, index: int) -> ShapeRecord: + return self._records[index] + + def ngeom_blob(self, index: int): + """The raw NGEOM buffer for adacpp fast-path consumers (tessellation, stream + export) — no hydration. ``None`` for pickle-kind records (those consumers + hydrate via :meth:`geometry` and serialize with booleans on the fly).""" + rec = self._records[index] + if rec.kind != "ngeom": + return None + blob = self._blobs[index] + if rec.compressed: + return zlib.decompress(blob) + return blob + + def geometry(self, index: int) -> Geometry: + """Hydrate the full ``ada.geom.Geometry`` for one shape (weakref-cached, with + a small strong LRU window for back-to-back access).""" + geom = self._geom_cache.get(index) + if geom is not None: + self._lru_touch(index, geom) + return geom + rec = self._records[index] + bool_ops: list[BooleanOperation] = [] + if rec.kind == "ngeom": + from ada.cadit.ngeom.deserialize import ( + deserialize_geometries, + promote_closed_shell, + ) + + roots = deserialize_geometries(self.ngeom_blob(index)) + if not roots: + raise ValueError(f"NGEOM blob for {rec.gid!r} decoded to zero roots") + # NGEOM doesn't record shell closedness; restore ClosedShell for manifold + # B-rep roots so hydration matches the Python stream reader's form. + inner = promote_closed_shell(roots[0][1]) + else: + payload = self._blobs[index] + if rec.compressed: + payload = zlib.decompress(payload) + inner, bool_ops = pickle.loads(payload) + hydrated = Geometry( + id=rec.gid, + geometry=inner, + color=rec.color, + bool_operations=list(bool_ops), + transforms=rec.transforms, + instance_paths=rec.instance_paths, + ) + self._geom_cache[index] = hydrated + self._lru_touch(index, hydrated) + return hydrated + + def _lru_touch(self, index: int, geom: Geometry) -> None: + cap = self.hydration_cache_size + if cap <= 0: + return + lru = self._geom_lru + lru[index] = geom + lru.move_to_end(index) + while len(lru) > cap: + lru.popitem(last=False) + + # --- diagnostics / pickling ----------------------------------------------------------- + + @property + def nbytes(self) -> int: + """Total stored blob bytes (as-held, i.e. compressed size when compressed).""" + return sum(memoryview(b).nbytes for b in self._blobs) + + def __getstate__(self): + # Buffer views (e.g. capsule-backed ndarrays from adacpp) coerce to bytes — + # the one accepted pickle-time copy. The hydration caches never travel. + return { + "blobs": [b if isinstance(b, bytes) else bytes(b) for b in self._blobs], + "records": self._records, + "compress": self.compress, + "hydration_cache_size": self.hydration_cache_size, + } + + def __setstate__(self, state): + self._blobs = state["blobs"] + self._records = state["records"] + self.compress = state["compress"] + self.hydration_cache_size = state.get("hydration_cache_size", 16) + self._geom_cache = weakref.WeakValueDictionary() + self._geom_lru = OrderedDict() diff --git a/src/ada/api/spatial/assembly.py b/src/ada/api/spatial/assembly.py index 0c168902b..5d1330902 100644 --- a/src/ada/api/spatial/assembly.py +++ b/src/ada/api/spatial/assembly.py @@ -2,7 +2,7 @@ import os import pathlib -from typing import TYPE_CHECKING, Callable, Union +from typing import TYPE_CHECKING, Callable, Literal, Union from ada.api.spatial.part import Part from ada.api.user import User @@ -98,8 +98,29 @@ def __getstate__(self): def __setstate__(self, state): self.__dict__.update(state) - def read_ifc(self, ifc_file: str | os.PathLike | ifcopenshell.file, data_only=False, elements2part=None): - """Import from IFC file.""" + def read_ifc( + self, + ifc_file: str | os.PathLike | ifcopenshell.file, + data_only=False, + elements2part=None, + reader: Literal["ifcopenshell", "native"] | None = None, + ): + """Import from IFC file. + + ``reader="native"`` uses adacpp's pure-C++ IFC reader (IfcNgeomStream) to build a + geometry-shapes Part/ShapeProxy tree (no ifcopenshell/OCC) — colour + spatial hierarchy from + the C++ resolver; does NOT reconstruct typed Beam/Plate objects. Default (``ifcopenshell``) + is the full typed reader. + """ + if reader == "native": + from ada.cadit.ifc.read.native_reader import native_adacpp_ifc_available, native_read_ifc_into + + if not native_adacpp_ifc_available(): + raise RuntimeError("reader='native' requires adacpp with IfcNgeomStream") + native_read_ifc_into(self, ifc_file) + if isinstance(ifc_file, (str, os.PathLike)): + self.ifc_store.ifc_file_path = pathlib.Path(ifc_file) + return self.ifc_store.load_ifc_content_from_file(ifc_file, data_only=data_only, elements2part=elements2part) def read_fem( @@ -272,6 +293,7 @@ def to_ifc( geom_repr_override: dict[str, GeomRepr] = None, streaming=False, merge_strategy=None, + writer: Literal["ifcopenshell", "native"] | None = None, ) -> ifcopenshell.file: import ifcopenshell.validate @@ -280,6 +302,23 @@ def to_ifc( else: destination = pathlib.Path(destination).resolve().absolute() + # Native pure-C++ writer (adacpp blobs_to_ifc): emit analytic IFC solids from each shape's + # NGEOM blob — no ifcopenshell/OCC. Needs an on-disk destination + lazy ShapeProxy shapes + # (pairs with from_ifc(reader="native")). Raises if no shape carries a blob. + if writer == "native": + if destination == "object": + raise ValueError("to_ifc(writer='native') needs an on-disk destination") + from ada.cadit.ifc.write.native_ifc_writer import native_ifc_writer_available, native_write_ifc + + if not native_ifc_writer_available(): + raise RuntimeError("writer='native' requires adacpp with blobs_to_ifc") + os.makedirs(destination.parent, exist_ok=True) + native_write_ifc(self, destination) + if validate: + ifcopenshell.validate.validate(str(destination), logger) + logger.info("IFC file creation complete (native)") + return None + logger.info(f'Beginning writing to IFC file "{destination}" using IfcOpenShell') # Memory-bounded path: hand-author Plate solids as SPF text instead of diff --git a/src/ada/api/spatial/part.py b/src/ada/api/spatial/part.py index 10ddb97ea..b3b536337 100644 --- a/src/ada/api/spatial/part.py +++ b/src/ada/api/spatial/part.py @@ -707,34 +707,114 @@ def _tree_parent(paths) -> Part: parent = p return parent - # "native": adacpp's C++ NGEOM parser hydrated to Geometry (no Python tokenizer); same yield - # contract, so the Shape-wrap + product-tree below is identical. - if reader == "native": - from ada.cadit.step.read.native_reader import ( - native_adacpp_step_available, - native_stream_read_step, - ) + from ada.config import Config - if not native_adacpp_step_available(): - raise StepStreamUnsupported("reader='native' requires the adacpp stream_step_to_ngeom entry point") - geom_iter = native_stream_read_step(step_path) - else: - geom_iter = stream_read_step(step_path, local_pool=local_pool, tolerant=tolerant) + # Lazy shape store (default on): retain each solid as its compact blob and mint + # ShapeProxy objects that hydrate on demand, instead of holding the whole model + # as ada.geom trees (~10x resident memory on large assemblies). Opt out via + # ADA_CAD_LAZY_SHAPE_STORE=false. + store = None + if Config().cad_lazy_shape_store: + from ada.api.shapes import ShapeProxy, ShapeStore - new_shapes = [] # (parent_part, shape) - try: - for i, geometry in enumerate(geom_iter): - shp_name = str(geometry.id) if geometry.id not in (None, "") else f"{ada_name}_{i}" - shp = Shape( + store = ShapeStore(compress=Config().cad_shape_store_compress) + + def _mint_shape(shp_name, geometry) -> Shape: + if store is None: + return Shape( shp_name, geom=geometry, color=colour or geometry.color, opacity=opacity, units=source_units ) - parent = _tree_parent(geometry.instance_paths) if product_tree else self - new_shapes.append((parent, shp)) - except StepStreamUnsupported: - if reader != "auto": - raise - logger.info("read_step_file: streaming reader hit an unsupported entity; falling back to OCC reader") - return False + idx = store.add_geometry(geometry) + return ShapeProxy(shp_name, store, idx, color=colour or geometry.color, opacity=opacity, units=source_units) + + new_shapes = [] # (parent_part, shape) + + # "native": adacpp's C++ NGEOM parser. "auto" also probes it first (matching + # iter_from_step's read_solids): the native parse is faster and, with the lazy + # store, keeps the per-solid NGEOM buffer as-is — no hydration at import at all. + # A first-solid hydrate probe guards against files whose buffers the Python + # decode path can't handle; those fall back to the pure-Python reader ("auto") + # or raise ("native"). + use_native = False + if reader in ("native", "auto"): + from ada.cadit.step.read.native_reader import native_adacpp_step_available + + if native_adacpp_step_available(): + use_native = True + elif reader == "native": + raise StepStreamUnsupported("reader='native' requires the adacpp stream_step_to_ngeom entry point") + + # The native reader is solid-only; for "auto" a file carrying loose curve/geometric-set + # roots (wireframe bodies — SAT wire bodies, evaluated alignment reference curves) would + # silently lose them. Route such files to the lossless pure-Python reader instead so + # "auto" keeps its no-geometry-left-behind contract. ("native" stays as asked.) + if use_native and reader == "auto": + from ada.cadit.step.write._solid_source import step_has_curve_set_roots + + if step_has_curve_set_roots(step_path): + use_native = False + tolerant = True # the pure-Python fallback must read the curve sets, not raise + + if use_native: + from ada.cadit.ngeom.deserialize import ( + deserialize_geometries, + promote_closed_shell, + ) + from ada.cadit.step.read.native_reader import native_stream_read_step_blobs + from ada.cadit.step.write._solid_source import NATIVE_DECODE_ERRORS + from ada.geom import Geometry + + try: + for i, (blob, gid, color, mats, paths) in enumerate(native_stream_read_step_blobs(step_path)): + if i == 0: + deserialize_geometries(blob) # hydrate-probe (result discarded) + shp_name = gid if gid not in (None, "") else f"{ada_name}_{i}" + if store is not None: + idx = store.add_blob( + blob, gid=shp_name, color=color, transforms=(mats or None), instance_paths=(paths or None) + ) + shp = ShapeProxy( + shp_name, store, idx, color=colour or color, opacity=opacity, units=source_units + ) + else: + # Eager Shapes get the ClosedShell promotion here (the lazy + # store applies it at hydration) — the streaming reader itself + # yields bare face-sets to keep the exporters' hot loop lean. + geometry = Geometry( + id=shp_name, + geometry=promote_closed_shell(deserialize_geometries(blob)[0][1]), + color=color, + transforms=(mats or None), + instance_paths=(paths or None), + ) + shp = _mint_shape(shp_name, geometry) + parent = _tree_parent(paths) if product_tree else self + new_shapes.append((parent, shp)) + except (*NATIVE_DECODE_ERRORS, RuntimeError) as exc: + if reader == "native" or new_shapes: + # native was forced, or solids were already committed (a mid-stream + # failure can't be un-yielded) — fail loudly over silent truncation. + raise + logger.info("read_step_file: native STEP reader failed (%s); using the pure-Python reader", exc) + use_native = False + if use_native and not new_shapes and reader == "auto": + # Native recognised no solids; the pure-Python reader supports entity + # kinds the native one skips, so give it the file before the OCC fallback. + use_native = False + + if not use_native: + geom_iter = stream_read_step(step_path, local_pool=local_pool, tolerant=tolerant) + try: + for i, geometry in enumerate(geom_iter): + shp_name = str(geometry.id) if geometry.id not in (None, "") else f"{ada_name}_{i}" + shp = _mint_shape(shp_name, geometry) + parent = _tree_parent(geometry.instance_paths) if product_tree else self + new_shapes.append((parent, shp)) + except StepStreamUnsupported: + if reader != "auto": + raise + logger.info("read_step_file: streaming reader hit an unsupported entity; falling back to OCC reader") + return False # A zero-yield on a non-empty file means the streaming reader didn't # recognise the structure — fall back to OCC rather than silently @@ -934,13 +1014,20 @@ def iter_objects_from_fem( else: yield from self._iter_merged_plates_from_fem(merge_strategy, detached, mat_cache) - def _iter_merged_plates_from_fem(self, merge_strategy, detached: bool, mat_cache: dict | None): - """Wrap each object-free merged :class:`FaceData` in one transient Plate. + def _iter_merged_plates_from_fem(self, merge_strategy, detached: bool, mat_cache: dict | None, chunk: int = 2048): + """Wrap the object-free merged :class:`FaceData` records in transient Plates. + + Tri/quad faces are buffered into bounded chunks and built through the + vectorized :meth:`CurvePoly2d.from_fem_shells_batch` (bitwise-identical to + the per-face ``Plate.from_fem_shell`` — the batch path escapes degenerate + rows back to it). ``chunk`` bounds the buffered face count, keeping the + streaming exporters' peak memory bounded; yield order matches the face + engine's stream order exactly. Merged N-gons use the general + ``from_3d_points`` path.""" + import numpy as np - Bounded: builds and yields a single Plate at a time. Tri/quad faces take - the fast ``Plate.from_fem_shell`` path; merged N-gons use the general - ``from_3d_points``.""" from ada import Plate + from ada.api.curves import CurvePoly2d from ada.fem.formats.mesh_faces import faces_from_fem # name -> Material, resolved once from this part's shell sections so the @@ -951,14 +1038,57 @@ def _iter_merged_plates_from_fem(self, merge_strategy, detached: bool, mat_cache if mat is not None and mat.name not in mats: mats[mat.name] = mat + buf: list = [] # pending tri/quad FaceData, flushed as one vectorized batch + + def _flush(): + if not buf: + return + by_k: dict[int, list[int]] = {} + for i, f in enumerate(buf): + by_k.setdefault(len(f.outline), []).append(i) + polys: list = [None] * len(buf) + for _k, idxs in by_k.items(): + pts = np.stack([np.asarray(buf[i].outline, dtype=float) for i in idxs]) + for i, poly in zip(idxs, CurvePoly2d.from_fem_shells_batch(pts, parent=self)): + polys[i] = poly + for f, poly in zip(buf, polys): + mat = mats.get(f.material, f.material) + if poly is None: + yield Plate.from_fem_shell(f.name, f.outline, f.thickness, mat=mat, parent=self, detached=detached) + else: + yield Plate(f.name, poly, f.thickness, mat=mat, parent=self, detached=detached) + buf.clear() + for face in faces_from_fem(self.fem, merge_strategy): - mat = mats.get(face.material, face.material) - if len(face.outline) <= 4: - yield Plate.from_fem_shell( - face.name, face.outline, face.thickness, mat=mat, parent=self, detached=detached + if face.geom_face is not None: + # Analytic patch (SURFACE/PANEL: trimmed cylinder or B-spline + # panel) → one curved-plate concept, mirroring the FEM surface + # reconstruction path. Flush pending tri/quads first so the + # stream order is unchanged. + yield from _flush() + from ada import PlateCurved + from ada.core.guid import create_guid + from ada.geom import Geometry + + mat = mats.get(face.material, face.material) + yield PlateCurved( + face.name, + Geometry(create_guid(), face.geom_face, None), + float(face.thickness), + mat=mat, + extrude_as_solid=True, + parent=self, ) + elif len(face.outline) <= 4: + buf.append(face) + if len(buf) >= chunk: + yield from _flush() else: + # Flush pending tri/quads first so the stream order is unchanged. + yield from _flush() + mat = mats.get(face.material, face.material) yield Plate.from_3d_points(face.name, face.outline, face.thickness, mat=mat, parent=self) + yield from _flush() def get_part(self, name: str, search_all_parts_in_assembly=False) -> Part | None: """Get part by name.""" @@ -1181,9 +1311,21 @@ def get_all_physical_objects( physical_objects.append(all_as_iterable) if by_type is not None: + from ada.api.shapes import ShapeProxy + if not isinstance(by_type, (list, tuple)): by_type = (by_type,) - res = filter(lambda x: type(x) in by_type, chain.from_iterable(physical_objects)) + + def _match_type(x, _by=tuple(by_type)): + # Exact-type filter, but a lazy ShapeProxy counts as its public type + # Shape (a proxy is an implementation detail of the import path, not + # a distinct kind — Shape subclasses like PrimBox stay excluded). + t = type(x) + if t is ShapeProxy: + t = Shape + return t in _by + + res = filter(_match_type, chain.from_iterable(physical_objects)) elif by_metadata is not None: res = filter( lambda x: all(x.metadata.get(key) == value for key, value in by_metadata.items()), diff --git a/src/ada/api/transforms.py b/src/ada/api/transforms.py index 476e8019c..b4a0b6d4f 100644 --- a/src/ada/api/transforms.py +++ b/src/ada/api/transforms.py @@ -141,11 +141,17 @@ def from_axis3d(axis: Axis2Placement3D) -> Placement: @staticmethod def from_4x4_matrix(matrix: np.ndarray) -> Placement: + # Extract the axes from the matrix ROWS, matching from_axis_angle / from_quaternion / + # rotate / get_absolute_placement (all row-based) and ``rot_matrix`` itself. Reading + # COLUMNS here (the old behaviour) stored the TRANSPOSE — for a rotation the inverse — + # so a rotated IFC ObjectPlacement rendered on the wrong world axis and the round-trip + # ``from_4x4_matrix(M).get_matrix4x4() == M`` was broken. See test_placement_convention. + matrix = np.asarray(matrix) return Placement( origin=matrix[:3, 3], - xdir=matrix[:3, 0], - ydir=matrix[:3, 1], - zdir=matrix[:3, 2], + xdir=matrix[0, :3], + ydir=matrix[1, :3], + zdir=matrix[2, :3], ) def get_absolute_placement(self, include_rotations=False) -> Placement: diff --git a/src/ada/cad/__init__.py b/src/ada/cad/__init__.py index 59894ba48..6972fe0ec 100644 --- a/src/ada/cad/__init__.py +++ b/src/ada/cad/__init__.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from ada.cad.registry import ( # noqa: E402 - registry is stdlib-only, no ada.cad cycle + DEFAULT_STREAM_TESS_ANGULAR_DEG, CadBackendName, CadConfig, StepReader, @@ -115,6 +116,9 @@ class BatchMesh: indices: Any # flat uint32, length = 3 * num_triangles groups: "list[MeshGroup]" normals: Any = None # flat float32, len == positions, or None if not supplied + mesh_type: int = 4 # glTF primitive mode of ``indices`` — 4=TRIANGLES (default), + # 1=LINES for curve-only bodies (alignment axes, trimmed/segmented curves). A single + # stream blob is one homogeneous root, so one mode covers the whole BatchMesh. def tessellate_batch_via_loop(backend, shapes, linear_deflection: float = -1.0) -> "BatchMesh": @@ -243,6 +247,7 @@ def build(self, geometry: "Geometry") -> ShapeHandle: # being ported to adacpp C++ incrementally; types not yet ported raise # NotImplementedError rather than borrowing pythonocc. End goal: full # parity with OccBackend. See dap plan/v3 Phase 7. + import ada.geom.curves as gcu import ada.geom.solids as so import ada.geom.surfaces as su from ada.api.beams.geom_beams import parametric_profile_to_arbitrary @@ -388,20 +393,24 @@ def _arbitrary(area): polygons = [] for cfs in g.fbsm_faces: - for fb in cfs.cfs_faces: - if not isinstance(fb.bound, cu.PolyLoop): - raise NotImplementedError( - f"AdacppBackend.build: FaceBasedSurfaceModel bound " - f"{type(fb.bound).__name__!r} not yet ported to adacpp." - ) - polygons.append([self._xyz(p) for p in fb.bound.polygon]) + for face in cfs.cfs_faces: # geo_su.Face, each carrying one or more FaceBound + for fb in face.bounds: + if not isinstance(fb.bound, cu.PolyLoop): + raise NotImplementedError( + f"AdacppBackend.build: FaceBasedSurfaceModel bound " + f"{type(fb.bound).__name__!r} not yet ported to adacpp." + ) + polygons.append([self._xyz(p) for p in fb.bound.polygon]) shape = self._cad.build_face_based_surface_model(polygons) - elif isinstance(g, (su.ShellBasedSurfaceModel, su.OpenShell, su.ClosedShell)): + elif isinstance(g, (su.ShellBasedSurfaceModel, su.OpenShell, su.ClosedShell, su.ConnectedFaceSet)): # Sew the member faces into one shell handle. Each face is built through the # AdvancedFace path; open shells (IfcShellBasedSurfaceModel) don't bound a # volume, so sew_faces (BRepBuilderAPI_Sewing) is used rather than # make_volumes_from_faces. Mirrors OccBackend's make_shell_from_shell_based_ - # surface_geom / make_open_shell_from_geom. + # surface_geom / make_open_shell_from_geom. Bare ConnectedFaceSet is the + # native NGEOM reader's B-rep root form (closedness not recorded in the + # buffer; hydration promotes verified-closed sets to ClosedShell, the rest + # arrive here) — same face-sew as the shells. from ada.geom import Geometry as _Geometry boundary_shells = g.sbsm_boundary if isinstance(g, su.ShellBasedSurfaceModel) else [g] @@ -409,12 +418,15 @@ def _arbitrary(area): if not face_handles: raise NotImplementedError("AdacppBackend.build: shell model has no faces") shape = self._cad.sew_faces(face_handles) - elif isinstance(g, su.AdvancedFace): + elif isinstance(g, (su.AdvancedFace, su.FaceSurface)): # B-spline (PlateCurved / loft-derived / SAT) faces. With bounds, the # surface is trimmed to the boundary wire(s) — each OrientedEdge with # a supplied pcurve drives its edge from surface(pcurve(t)) (the # SAT-pcurve path of OccBackend.make_face_from_geom). Without bounds, # the natural-UV face. Analytic surfaces aren't ported yet. + # FaceSurface is AdvancedFace's structurally-identical sibling (same + # face_surface/bounds/same_sense, NOT a subclass) — it is what NGEOM + # hydration yields for native-read B-rep faces. surf = g.face_surface if isinstance(surf, su.Plane) and g.bounds: # Planar AdvancedFace (flat SAT/IFC plates): plane inferred from the boundary @@ -534,6 +546,15 @@ def _arbitrary(area): self._cad.polygon_face([self._xyz(g.coordinates[i - 1]) for i in face_idx]) for face_idx in g.faces ] shape = self._cad.sew_faces(faces) + elif isinstance(g, su.TriangulatedFaceSet): + # Triangle mesh (IfcTriangulatedFaceSet): one planar face per 1-based + # index triple, sewn into a shell — same treatment as PolygonalFaceSet. + idx = [int(i) for i in g.indices] + faces = [ + self._cad.polygon_face([self._xyz(g.coordinates[i - 1]) for i in idx[k : k + 3]]) + for k in range(0, len(idx), 3) + ] + shape = self._cad.sew_faces(faces) elif isinstance(g, so.RectangularPyramid): # Rectangular base + 4 triangular sides (apex centred above), placed at the frame. x, y, z = g.x_length, g.y_length, g.z_length @@ -570,6 +591,12 @@ def _arbitrary(area): if len(edge_list) < 3: raise NotImplementedError("AdacppBackend.build: WireFilledFace needs >=3 boundary edges") shape = self._cad.build_filled_face([self._encode_oriented_edge(oe) for oe in edge_list]) + elif isinstance(g, gcu.CURVE_GEOM_TUPLE): + # Bare curve bodies (sectionless SAT wire bodies, construction + # wireframes): build the wire so B-rep exports carry them — STEP + # writes wires natively (GEOMETRIC_CURVE_SET). Mirrors + # geom_to_occ_geom's CURVE_GEOM_TUPLE arm. + shape = self._cad.build_wire(self._encode_curve(g)) else: raise NotImplementedError( f"AdacppBackend.build: ada.geom type {type(g).__name__!r} is not yet ported to " @@ -645,18 +672,88 @@ def _encode_curve(self, curve) -> list[list[float]]: if isinstance(curve, cu.Circle): axis = self._xyz(curve.position.axis) if curve.position.axis is not None else [0.0, 0.0, 1.0] return [[2.0, *self._xyz(curve.position.location), *axis, float(curve.radius)]] + if isinstance(curve, cu.Edge) and not isinstance(curve, cu.ArcLine): + # Bare line segment (sectionless SAT wire body). + return [[0.0, *self._xyz(curve.start), *self._xyz(curve.end)]] + if isinstance(curve, cu.ArcLine): + return [[1.0, *self._xyz(curve.start), *self._xyz(curve.midpoint), *self._xyz(curve.end)]] if isinstance(curve, cu.PolyLine): pts = curve.points return [[0.0, *self._xyz(a), *self._xyz(b)] for a, b in zip(pts[:-1], pts[1:])] if isinstance(curve, cu.TrimmedCurve): + import numpy as _np + from ada.geom.points import Point as _Point b, t1, t2 = curve.basis_curve, curve.trim1, curve.trim2 - if isinstance(b, cu.Line) and isinstance(t1, _Point) and isinstance(t2, _Point): - return [[0.0, *self._xyz(t1), *self._xyz(t2)]] - if isinstance(b, cu.Circle) and isinstance(t1, _Point) and isinstance(t2, _Point): - mid = self._arc_midpoint(b, t1, t2, curve.sense_agreement) - return [[1.0, *self._xyz(t1), *mid, *self._xyz(t2)]] + if isinstance(b, cu.Line): + # Parameter trims evaluate on P(t) = pnt + t*dir — the reader + # keeps the IfcVector magnitude in ``dir`` so t is unscaled. + def _line_pt(t): + if isinstance(t, _Point): + return self._xyz(t) + p = _np.asarray(self._xyz(b.pnt), dtype=float) + d = _np.asarray(self._xyz(b.dir), dtype=float) + return [float(v) for v in p + float(t) * d] + + return [[0.0, *_line_pt(t1), *_line_pt(t2)]] + if isinstance(b, cu.Circle): + # Parameter trims are angles (radians, normalized at read) in + # the circle's own x/y frame — the same frame _arc_midpoint + # measures in, so sense/wrap handling is shared. + def _circle_pt(t): + if isinstance(t, _Point): + return self._xyz(t) + c = _np.asarray(self._xyz(b.position.location), dtype=float) + z = _np.asarray( + self._xyz(b.position.axis) if b.position.axis is not None else [0, 0, 1], dtype=float + ) + x = _np.asarray( + self._xyz(b.position.ref_direction) if b.position.ref_direction is not None else [1, 0, 0], + dtype=float, + ) + z = z / (_np.linalg.norm(z) or 1.0) + x = x / (_np.linalg.norm(x) or 1.0) + y = _np.cross(z, x) + w = c + float(b.radius) * (_np.cos(float(t)) * x + _np.sin(float(t)) * y) + return [float(v) for v in w] + + p1, p2 = _circle_pt(t1), _circle_pt(t2) + mid = self._arc_midpoint(b, p1, p2, curve.sense_agreement) + return [[1.0, *p1, *mid, *p2]] + if isinstance(b, cu.Ellipse): + # adacpp has no ellipse edge record — sample the trimmed arc + # (parametric angle; trims normalized to radians at read) into + # a fine polyline. Profile use only, so chord error at 64 + # segments/full-turn is well under tessellation deflection. + c = _np.asarray(self._xyz(b.position.location), dtype=float) + z = _np.asarray(self._xyz(b.position.axis) if b.position.axis is not None else [0, 0, 1], dtype=float) + x = _np.asarray( + self._xyz(b.position.ref_direction) if b.position.ref_direction is not None else [1, 0, 0], + dtype=float, + ) + z = z / (_np.linalg.norm(z) or 1.0) + x = x / (_np.linalg.norm(x) or 1.0) + y = _np.cross(z, x) + sa1, sa2 = float(b.semi_axis1), float(b.semi_axis2) + + def _param_of(t): + if not isinstance(t, _Point): + return float(t) + d = _np.asarray(self._xyz(t), dtype=float) - c + return float(_np.arctan2(float(d @ y) / sa2, float(d @ x) / sa1)) + + a0, a1 = _param_of(t1), _param_of(t2) + if curve.sense_agreement and a1 <= a0: + a1 += 2.0 * _np.pi + elif not curve.sense_agreement and a1 >= a0: + a1 -= 2.0 * _np.pi + n = max(8, int(abs(a1 - a0) / (2.0 * _np.pi) * 64)) + ts = _np.linspace(a0, a1, n + 1) + pts = [c + sa1 * _np.cos(t) * x + sa2 * _np.sin(t) * y for t in ts] + return [ + [0.0, *[float(v) for v in p], *[float(v) for v in q]] for p, q in zip(pts[:-1], pts[1:]) + ] raise NotImplementedError( f"AdacppBackend.build: TrimmedCurve basis {type(b).__name__!r} not yet ported to adacpp." ) @@ -665,6 +762,27 @@ def _encode_curve(self, curve) -> list[list[float]]: for seg in curve.segments: edges.extend(self._encode_curve(seg.parent_curve)) return edges + if isinstance(curve, cu.GeometricCurveSet): + # Loose curve collection (STEP wireframe body) — concatenate the + # member encodings; the records stay independent edges. + edges = [] + for element in curve.elements: + edges.extend(self._encode_curve(element)) + return edges + if isinstance(curve, cu.GradientCurve): + # Alignment directrix (IFC4x3): clothoid segments have no analytic + # edge record — encode the sampled polyline (shared with the + # NGEOM SweepN tessellation path, which uses the same evaluator). + import numpy as _np + + from ada.cadit.ngeom._alignment_sweep import gradient_curve_points + + pts = gradient_curve_points(curve, n_per=100) + return [ + [0.0, *[float(v) for v in a], *[float(v) for v in b]] + for a, b in zip(pts[:-1], pts[1:]) + if float(_np.linalg.norm(b - a)) > 1e-9 + ] raise NotImplementedError( f"AdacppBackend.build: profile curve {type(curve).__name__!r} not yet ported to adacpp." ) @@ -902,11 +1020,17 @@ def tessellate_batch(self, shapes: "list[ShapeHandle]", linear_deflection: float for g in mesh.groups ] nrm = np.asarray(mesh.normals) + # mesh_type: glTF primitive mode (4=TRIANGLES, 1=LINES for curve-only bodies). Older + # adacpp builds' Mesh has no mesh_type attr → default TRIANGLES. The binding returns a + # MeshType enum whose int value already equals the glTF mode. + mt = getattr(mesh, "mesh_type", None) + mesh_type = int(getattr(mt, "value", mt)) if mt is not None else 4 return BatchMesh( positions=np.asarray(mesh.positions), indices=np.asarray(mesh.indices), groups=groups, normals=nrm if nrm.size else None, + mesh_type=mesh_type, ) def ifc_taxonomy_settings(self) -> "list[dict]": @@ -922,9 +1046,10 @@ def tessellate_stream( items: "list[tuple[str, object]]", pipeline: str = "libtess2", deflection: float = 0.0, - angular_deg: float = 20.0, + angular_deg: float = DEFAULT_STREAM_TESS_ANGULAR_DEG, settings: "dict | None" = None, threads: int = 1, + model_scale: float = 0.0, ) -> "BatchMesh": """Tessellate a stream of ``(id, ada.geom geometry)`` via adacpp's NGEOM pipeline. @@ -932,8 +1057,36 @@ def tessellate_stream( per-object ``build``/ShapeHandle round-trip) and tessellates it in one C++ call, returning a combined ``BatchMesh`` with a group per input id (``node_id`` = the item's position). ``pipeline``: ``libtess2`` (OCC-free) | ``occ`` | ``cgal`` - (ifcopenshell taxonomy kernels). ``geometry`` is an ``ada.geom`` ``FaceSurface`` or - ``ConnectedFaceSet`` (unmappable items are skipped by the serializer).""" + (ifcopenshell taxonomy kernels). ``geometry`` is an ``ada.geom`` solid/face-set, + or a ``core.Geometry`` wrapper — the wrapper's ``bool_operations`` are folded + into the buffer as a BOOLEAN_RESULT chain (unmappable items are skipped).""" + from ada.cadit.ngeom import serialize_geometries + + return self.tessellate_stream_buffer( + serialize_geometries(items), + pipeline=pipeline, + deflection=deflection, + angular_deg=angular_deg, + settings=settings, + threads=threads, + model_scale=model_scale, + ) + + def tessellate_stream_buffer( + self, + buffer, + *, + pipeline: str = "libtess2", + deflection: float = 0.0, + angular_deg: float = DEFAULT_STREAM_TESS_ANGULAR_DEG, + settings: "dict | None" = None, + threads: int = 1, + model_scale: float = 0.0, + ) -> "BatchMesh": + """Tessellate a pre-encoded NGEOM buffer — the fast path for blobs already in + the neutral form (a lazy ``ShapeStore``'s stored solid, a cached ``.ngeom`` + file): no hydration and no re-serialization, the buffer goes straight to the + C++ kernel.""" fn = getattr(self._cad, "tessellate_stream", None) if fn is None: raise NotImplementedError( @@ -941,16 +1094,26 @@ def tessellate_stream( ) import numpy as np - from ada.cadit.ngeom import serialize_geometries - - buffer = serialize_geometries(items) + if not isinstance(buffer, (bytes, bytearray)): + # adacpp's binding currently takes nb::bytes only; drop this coercion once + # the buffer-protocol/zero-copy adacpp change lands. + buffer = bytes(buffer) # ``settings`` overrides the ifcopenshell ConversionSettings for the taxonomy paths # (occ/cgal/hybrid); ignored by libtess2. ``threads`` (>1) parallelises a root's faces # in the libtess2 path — opt-in, so the STEP->GLB process pool (which parallelises across # solids) stays serial per call and doesn't oversubscribe. The signature grew # (settings, then threads); try the fullest form and fall back for older adacpp builds. try: - mesh = fn(buffer, pipeline, deflection, angular_deg, dict(settings or {}), int(threads)) + # model_scale (>0 => adaptive per-surface density) is the newest param; try it first + # and fall back for older adacpp builds (which drop it along with threads/settings). + try: + mesh = fn( + buffer, pipeline, deflection, angular_deg, dict(settings or {}), int(threads), float(model_scale) + ) + except TypeError: + if float(model_scale) > 0.0: + logger.debug("adacpp build has no tessellate_stream model_scale param; fixed angular_deg") + mesh = fn(buffer, pipeline, deflection, angular_deg, dict(settings or {}), int(threads)) except TypeError: if int(threads) > 1: logger.debug("adacpp build has no tessellate_stream threads param; running serial") @@ -967,11 +1130,17 @@ def tessellate_stream( for g in mesh.groups ] nrm = np.asarray(mesh.normals) + # mesh_type: glTF primitive mode (4=TRIANGLES, 1=LINES for curve-only bodies). Older + # adacpp builds' Mesh has no mesh_type attr → default TRIANGLES. The binding returns a + # MeshType enum whose int value already equals the glTF mode. + mt = getattr(mesh, "mesh_type", None) + mesh_type = int(getattr(mt, "value", mt)) if mt is not None else 4 return BatchMesh( positions=np.asarray(mesh.positions), indices=np.asarray(mesh.indices), groups=groups, normals=nrm if nrm.size else None, + mesh_type=mesh_type, ) def bbox( diff --git a/src/ada/cad/registry.py b/src/ada/cad/registry.py index 421324cb7..67cdd2526 100644 --- a/src/ada/cad/registry.py +++ b/src/ada/cad/registry.py @@ -23,6 +23,51 @@ from enum import Enum from functools import lru_cache +# Corpus-wide defaults for the NGEOM stream (libtess2/adacpp) tessellator. angular_deg caps +# the arc-segment span for any curved geometry (revolves, pipes, cylinders, B-splines) — the +# linear deflection alone can't keep a large-radius arc smooth because its sag tolerance grows +# with radius. 10 deg (was 20) keeps a 7 m-radius revolved beam's bulge apex within ~1% of true; +# it roughly matches the OCC path's 0.2 rad. Override per-run with ADA_STREAM_TESS_ANGULAR / +# ADA_STREAM_TESS_DEFLECTION or per-model via CadConfig. +DEFAULT_STREAM_TESS_DEFLECTION = 2.0 +DEFAULT_STREAM_TESS_ANGULAR_DEG = 10.0 + +# Angular density mode. OFF (default): ``angular_deg`` is a fixed global ceiling for every curved +# surface (explicit-global-angle mode, backward compatible). ON: the ceiling is applied ADAPTIVELY +# per surface — a model-relative reference (``model_scale``, the model bbox diagonal) lets tiny +# curved features (bolts/pins in a large assembly, whose facets are sub-pixel) coarsen while large +# visible surfaces keep the fine angle. The relaxation itself lives in adacpp (angle_step); adapy +# only decides the mode and supplies ``model_scale``. Toggle with ADA_STREAM_TESS_ADAPTIVE. +DEFAULT_STREAM_TESS_ADAPTIVE = False + + +def stream_tess_defaults() -> tuple[float, float]: + """(deflection, angular_deg) for the NGEOM stream path: env override else the corpus default.""" + defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", str(DEFAULT_STREAM_TESS_DEFLECTION))) + ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) + return defl, ang + + +def stream_tess_adaptive() -> bool: + """Whether adaptive per-surface angular density is enabled (env override else the default OFF).""" + v = os.environ.get("ADA_STREAM_TESS_ADAPTIVE") + if v is None: + return DEFAULT_STREAM_TESS_ADAPTIVE + return v.strip().lower() not in {"0", "false", "no", "off", ""} + + +def stream_tess_model_scale() -> float: + """The model reference scale (world units) for adaptive density on the object stream path, or + 0.0 (off). Adaptive is meaningful only with a model scale, so this returns 0 unless adaptive is + enabled AND a scale is supplied via ADA_STREAM_TESS_MODEL_SCALE (the caller sets it once per + model — the native STEP->GLB path estimates its own; see ada.cadit.step.model_scale).""" + if not stream_tess_adaptive(): + return 0.0 + try: + return float(os.environ.get("ADA_STREAM_TESS_MODEL_SCALE", "0") or 0.0) + except ValueError: + return 0.0 + class CadBackendName(str, Enum): OCC = "occ" # pythonocc-core (native BRepMesh) @@ -87,12 +132,14 @@ class StepReader(str, Enum): for the common case and as robust/complete as OCC for the rest (no geometry skipped). ``STREAM`` is streaming-only (raises on out-of-scope entities). ``TOLERANT`` streams and *skips* the unsupported solids (never OOMs, but drops geometry — avoid as a default). ``OCC`` forces the - whole-file OCC reader (needed for scale/transform/rotate-on-import).""" + whole-file OCC reader (needed for scale/transform/rotate-on-import). ``NATIVE`` forces + adacpp's C++ NGEOM parser (fastest; ``AUTO`` probes it first when adacpp is available).""" AUTO = "auto" STREAM = "stream" TOLERANT = "tolerant" OCC = "occ" + NATIVE = "native" @dataclass @@ -102,8 +149,8 @@ class CadConfig: ``ada.from_step(..., cad_config=cfg)``).""" path: TessellationPath = TessellationPath.OCC - deflection: float = 2.0 - angular_deg: float = 20.0 + deflection: float = DEFAULT_STREAM_TESS_DEFLECTION + angular_deg: float = DEFAULT_STREAM_TESS_ANGULAR_DEG simplify: bool = False # meshopt cleanup (step2glb merge parity); adacpp paths only # The STEP read path the factories default to. AUTO = constant-memory streaming with an OCC # fallback for out-of-scope files — the most memory-efficient + robust default. Override per diff --git a/src/ada/cadit/gxml/write/stream_xml.py b/src/ada/cadit/gxml/write/stream_xml.py index f0b3da276..7954b7d79 100644 --- a/src/ada/cadit/gxml/write/stream_xml.py +++ b/src/ada/cadit/gxml/write/stream_xml.py @@ -233,7 +233,10 @@ def _stream_structures(part, fh, thickness_map, Beam, BeamTapered, Plate, merge_ else: from ada.fem.formats.mesh_faces import iter_faces - for face in iter_faces(part, merge_strategy): + # Genie XML has no curved-surface concept — keep the analytic strategies + # polygon-only so cylinder/panel patches merge as their coplanar flats + # instead of arriving as unrepresentable geom faces. + for face in iter_faces(part, merge_strategy, allow_analytic=False): tmp = ET.Element("structures") add_plate_polygon_data( face.name, face.outline, face.normal, thickness_map[face.thickness], face.material, tmp diff --git a/src/ada/cadit/gxml/write/write_beams.py b/src/ada/cadit/gxml/write/write_beams.py index 7af48768f..fceacfbaa 100644 --- a/src/ada/cadit/gxml/write/write_beams.py +++ b/src/ada/cadit/gxml/write/write_beams.py @@ -19,14 +19,25 @@ def add_beams(root: ET.Element, part: Part, sw: SatWriter = None): from ada import Beam, BeamTapered + from ada.api.beams import BeamRevolve, BeamSweep iter_beams = part.get_all_physical_objects(by_type=Beam) iter_taper = part.get_all_physical_objects(by_type=BeamTapered) + # Curved-axis beams: Genie XML's curved_beam element reads back as chord + # segments anyway (see read_beams.seg_to_beam), so emit the straight chord + # rather than silently dropping the member — mirrors stream_xml. + iter_revolve = part.get_all_physical_objects(by_type=BeamRevolve) + iter_sweep = part.get_all_physical_objects(by_type=BeamSweep) - for beam in itertools.chain(iter_beams, iter_taper): + for beam in itertools.chain(iter_beams, iter_taper, iter_revolve, iter_sweep): parent = beam.parent if isinstance(parent, Equipment) and parent.eq_repr != EquipRepr.AS_IS: continue + if isinstance(beam, (BeamRevolve, BeamSweep)): + logger.warning( + f"gxml-write: {type(beam).__name__} {beam.name!r} written as a straight chord beam " + "(curved axis not supported by the Genie XML writer)" + ) add_straight_beam(beam, root) diff --git a/src/ada/cadit/ifc/native_ifc_to_glb.py b/src/ada/cadit/ifc/native_ifc_to_glb.py new file mode 100644 index 000000000..e055beaa9 --- /dev/null +++ b/src/ada/cadit/ifc/native_ifc_to_glb.py @@ -0,0 +1,89 @@ +"""Fully-native IFC->GLB via adacpp (no ifcopenshell, no OCC). + +Calls a single adacpp C++ entry (``stream_ifc_to_glb``) that does everything in-process: the pure-C++ +IfcResolver resolves each product's geometry + presentation colour + spatial-structure path, libtess2 +tessellates (welded, crease-angle smooth normals), and the merge-by-colour GLB writer bakes it to +metres. The GLB carries the same viewer picking contract as the native STEP path — merge-by-colour +materials + per-material ``draw_ranges_node`` and a per-product ``id_hierarchy`` in +``scenes[0].extras`` — validated field-for-field against ``from_ifc`` -> GLB (geometry + colour + +hierarchy + names; IFC property sets never live in the GLB, they are fetched on selection). + +Single-threaded v1 (IfcResolver's colour/rel maps are built per instance); curve-only bodies +(alignment axes) are skipped. Honours the same ``ADA_STREAM_TESS_DEFLECTION`` / ``ADA_STREAM_TESS_ +ANGULAR`` env as the STEP native path. +""" + +from __future__ import annotations + +import os +import pathlib + +from ada.config import logger + + +def native_ifc_glb_available() -> bool: + """True if the adacpp native IFC->GLB entry point is importable.""" + try: + import adacpp # noqa: F401 + + return hasattr(adacpp.cad, "stream_ifc_to_glb") + except Exception: + return False + + +def native_ifc_to_glb( + ifc_path: str | pathlib.Path, + glb_path: str | pathlib.Path, + deflection: float | None = None, + angular_deg: float | None = None, + meshopt: bool = True, + on_progress=None, +) -> dict: + """Convert ``ifc_path`` to a GLB at ``glb_path`` with the native adacpp IFC pipeline. + + ``deflection`` / ``angular_deg`` default to the ``ADA_STREAM_TESS_DEFLECTION`` (2.0) / + ``ADA_STREAM_TESS_ANGULAR`` (20.0) env, matching the streaming path. ``meshopt`` (default on) bakes + ``EXT_meshopt_compression`` inline in the C++ writer. Returns ``{solids, total, skipped}``. Raises + if adacpp is unavailable or the conversion fails (the converter falls back per its fallback chain). + """ + import adacpp + + if deflection is None: + deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) + if angular_deg is None: + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) + + if on_progress is not None: + on_progress("adacpp-native-ifc", 0.1) + + # Same cgroup-aware worker count as the STEP streaming path (cpu quota - 1, capped at 3): bounds + # peak RSS and reserves a core for the parent's JetStream heartbeat. + try: + from ada.visit.scene_handling.scene_from_step_stream import _stream_workers + + num_threads = _stream_workers() + except Exception: + num_threads = 0 # C++ falls back to cgroup-aware effective_concurrency() + + n = adacpp.cad.stream_ifc_to_glb( + str(ifc_path), + str(glb_path), + deflection=deflection, + angular_deg=angular_deg, + meshopt=meshopt, + num_threads=num_threads, + ) + if n < 0: + raise RuntimeError(f"adacpp native stream_ifc_to_glb failed for {ifc_path}") + + logger.info("adacpp-native IFC->GLB: %s products -> %s", n, glb_path) + print( + f"[adacpp-native-ifc] {n} products -> {glb_path} " + f"(deflection={deflection}, angular={angular_deg}, meshopt={meshopt})", + flush=True, + ) + if on_progress is not None: + on_progress("ready", 1.0) + return {"solids": n, "total": n, "skipped": 0} diff --git a/src/ada/cadit/ifc/read/geom/curves.py b/src/ada/cadit/ifc/read/geom/curves.py index 9e0b4716c..2988ba57f 100644 --- a/src/ada/cadit/ifc/read/geom/curves.py +++ b/src/ada/cadit/ifc/read/geom/curves.py @@ -16,15 +16,24 @@ def get_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.CURVE_GEOM_TYP return b_spline_curve_with_knots(ifc_entity) elif ifc_entity.is_a("IfcTrimmedCurve"): return trimmed_curve(ifc_entity) + elif ifc_entity.is_a("IfcSegmentedReferenceCurve"): + # Subtype of IfcCompositeCurve (via IfcGradientCurve) — must precede both. + return segmented_reference_curve(ifc_entity) elif ifc_entity.is_a("IfcGradientCurve"): # Subtype of IfcCompositeCurve — must precede it. return gradient_curve(ifc_entity) elif ifc_entity.is_a("IfcCompositeCurve"): return composite_curve(ifc_entity) + elif ifc_entity.is_a("IfcCurveSegment"): + # A single alignment curve segment used directly as a representation item (an + # IfcAlignmentSegment's 'Axis'/'Segment' body). + return curve_segment(ifc_entity) elif ifc_entity.is_a("IfcLine"): return line(ifc_entity) elif ifc_entity.is_a("IfcClothoid"): return clothoid(ifc_entity) + elif ifc_entity.is_a("IfcCosineSpiral"): + return cosine_spiral(ifc_entity) elif ifc_entity.is_a("IfcCircle"): return circle(ifc_entity) elif ifc_entity.is_a("IfcEllipse"): @@ -38,7 +47,15 @@ def get_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.CURVE_GEOM_TYP def line(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Line: - return geo_cu.Line(pnt=Point(ifc_entity.Pnt.Coordinates), dir=Direction(ifc_entity.Dir.Orientation.DirectionRatios)) + # IfcLine.Dir is an IfcVector — its Magnitude scales the curve's + # parameterization (P(t) = Pnt + t*Dir). Fold it into the stored + # direction so parameter trims (IfcTrimmedCurve on a line basis) + # evaluate correctly; consumers that only need the direction are + # magnitude-agnostic. + vec = ifc_entity.Dir + mag = float(getattr(vec, "Magnitude", 1.0) or 1.0) + ratios = tuple(float(x) * mag for x in vec.Orientation.DirectionRatios) + return geo_cu.Line(pnt=Point(ifc_entity.Pnt.Coordinates), dir=Direction(ratios)) def circle(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Circle: @@ -61,6 +78,21 @@ def clothoid(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Clothoid: ) +def cosine_spiral(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.CosineSpiral: + """IfcCosineSpiral — 2D transition spiral about its Position; curvature varies as a cosine. + A1 = CosineTerm (required), A0 = ConstantTerm (optional).""" + pos = ifc_entity.Position + loc = pos.Location.Coordinates + rd = pos.RefDirection.DirectionRatios if pos.RefDirection is not None else (1.0, 0.0) + ct = ifc_entity.ConstantTerm + return geo_cu.CosineSpiral( + location=(float(loc[0]), float(loc[1])), + ref_direction=(float(rd[0]), float(rd[1])), + cosine_term=float(ifc_entity.CosineTerm), + constant_term=float(ct) if ct is not None else None, + ) + + def _measure(v) -> float: return float(v.wrappedValue if hasattr(v, "wrappedValue") else v) @@ -76,6 +108,14 @@ def curve_segment(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.CurveSegme rd = pl.RefDirection.DirectionRatios if pl.RefDirection is not None else (1.0, 0.0) location = (float(loc[0]), float(loc[1])) ref_direction = (float(rd[0]), float(rd[1])) + elif pl.is_a("IfcAxis2Placement3D"): + # Cant segments of an IfcSegmentedReferenceCurve are placed by a 3D axis placement; the + # extra (vertical) component of the location is the superelevation offset. Keep the full + # 3D location + ref direction — planar consumers slice [:2]. + loc = pl.Location.Coordinates + rd = pl.RefDirection.DirectionRatios if pl.RefDirection is not None else (1.0, 0.0, 0.0) + location = tuple(float(c) for c in loc) + ref_direction = tuple(float(c) for c in rd) else: # IfcAxis2PlacementLinear — planar location resolved at evaluation; carry the ref dir rd = pl.RefDirection.DirectionRatios if pl.RefDirection is not None else (1.0, 0.0) location = (0.0, 0.0) @@ -99,11 +139,25 @@ def gradient_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.GradientC ) +def segmented_reference_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.SegmentedReferenceCurve: + """IfcSegmentedReferenceCurve — cant (superelevation) segments over a BaseCurve (an + IfcGradientCurve). The base gives x,y,z; the segments add the vertical cant offset.""" + return geo_cu.SegmentedReferenceCurve( + base_curve=get_curve(ifc_entity.BaseCurve), + segments=[curve_segment(s) for s in ifc_entity.Segments], + self_intersect=bool(ifc_entity.SelfIntersect), + ) + + def ellipse(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Ellipse: - from .placement import axis3d + from .placement import axis2d_as_3d, axis3d + # Profile-plane ellipses (2D placement, no Axis attribute) lift into + # the z=0 plane — same treatment as circle() above. + pos = ifc_entity.Position + position = axis2d_as_3d(pos) if pos.is_a("IfcAxis2Placement2D") else axis3d(pos) return geo_cu.Ellipse( - position=axis3d(ifc_entity.Position), + position=position, semi_axis1=ifc_entity.SemiAxis1, semi_axis2=ifc_entity.SemiAxis2, ) @@ -136,11 +190,34 @@ def composite_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Composit return geo_cu.CompositeCurve(segments=segments, self_intersect=bool(ifc_entity.SelfIntersect)) +def _plane_angle_scale(f) -> float: + """Radians per file plane-angle unit (1.0 for SI radian files; 0.01745… + for files declaring a conversion-based DEGREE unit).""" + import ifcopenshell.util.unit as uu + + try: + return float(uu.calculate_unit_scale(f, unit_type="PLANEANGLEUNIT")) + except Exception: # noqa: BLE001 - missing/odd unit assignment: assume radians + return 1.0 + + def trimmed_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.TrimmedCurve: + trim1 = _trim_select(ifc_entity.Trim1) + trim2 = _trim_select(ifc_entity.Trim2) + if ifc_entity.BasisCurve.is_a("IfcConic"): + # Parameter trims on conics are angles expressed in the file's + # plane-angle unit — normalize to radians at read time so + # downstream evaluators need no unit context (the buildingSMART + # curve-parameter samples ship one file in degrees, one in radians). + scale = _plane_angle_scale(ifc_entity.file) + if isinstance(trim1, float): + trim1 *= scale + if isinstance(trim2, float): + trim2 *= scale return geo_cu.TrimmedCurve( basis_curve=get_curve(ifc_entity.BasisCurve), - trim1=_trim_select(ifc_entity.Trim1), - trim2=_trim_select(ifc_entity.Trim2), + trim1=trim1, + trim2=trim2, sense_agreement=ifc_entity.SenseAgreement, master_representation=ifc_entity.MasterRepresentation, ) @@ -204,8 +281,12 @@ def indexed_poly_curve(ifc_entity: ifcopenshell.entity_instance) -> geo_cu.Index for segment in ifc_entity.Segments: value = [x - 1 for x in segment.wrappedValue] if segment.is_a("IfcLineIndex"): - segments.append(geo_cu.Edge(pts[value[0]], pts[value[1]])) - else: + # IfcLineIndex is a POLYLINE through all its points (>=2) — one edge per + # consecutive pair. Taking only value[0]/value[1] dropped every intermediate + # vertex, collapsing multi-point runs (e.g. an I-section's flange outline). + for a, b in zip(value[:-1], value[1:]): + segments.append(geo_cu.Edge(pts[a], pts[b])) + else: # IfcArcIndex: exactly (start, mid, end) segments.append(geo_cu.ArcLine(pts[value[0]], pts[value[1]], pts[value[2]])) return geo_cu.IndexedPolyCurve(segments, ifc_entity.SelfIntersect) diff --git a/src/ada/cadit/ifc/read/geom/geom_reader.py b/src/ada/cadit/ifc/read/geom/geom_reader.py index 03bdfe16e..8b88ad012 100644 --- a/src/ada/cadit/ifc/read/geom/geom_reader.py +++ b/src/ada/cadit/ifc/read/geom/geom_reader.py @@ -20,12 +20,17 @@ ifc_rectangular_pyramid, ifc_sphere, revolved_solid_area, + sectioned_solid_horizontal, swept_disk_solid, ) from .surfaces import advanced_face, curve_bounded_plane from .surfaces import face as read_face from .surfaces import ( + closed_shell, + connected_face_set, + face_based_surface_model, half_space_solid, + open_shell, polygonal_face_set, shell_based_surface_model, triangulated_face_set, @@ -55,6 +60,9 @@ def import_geometry_from_ifc_geom(geom_repr: ifcopenshell.entity_instance) -> GE return revolved_solid_area(geom_repr) elif geom_repr.is_a("IfcFixedReferenceSweptAreaSolid"): return fixed_reference_swept_area_solid(geom_repr) + elif geom_repr.is_a("IfcSectionedSolidHorizontal"): + # Subtype of IfcSectionedSolid — a profile swept along an alignment directrix. + return sectioned_solid_horizontal(geom_repr) elif geom_repr.is_a("IfcSweptDiskSolid"): # Covers the IfcSweptDiskSolidPolygonal subtype too. return swept_disk_solid(geom_repr) @@ -76,6 +84,11 @@ def import_geometry_from_ifc_geom(geom_repr: ifcopenshell.entity_instance) -> GE # WithVoids is a sibling of FacetedBrep (both direct IfcManifoldSolidBrep subtypes), # so it must be matched explicitly — is_a("IfcFacetedBrep") does not cover it. return faceted_brep(geom_repr) + elif geom_repr.is_a("IfcAdvancedBrep"): + # IfcManifoldSolidBrep subtype: an outer ClosedShell of IfcAdvancedFaces (analytic / + # B-spline surfaces). Serialized like any closed shell; the AdvancedFaces carry their + # surface + trimming bounds. (Voids on IfcAdvancedBrepWithVoids ignored for now.) + return closed_shell(geom_repr.Outer) elif geom_repr.is_a("IfcAdvancedFace"): return advanced_face(geom_repr) elif geom_repr.is_a("IfcFace"): @@ -84,6 +97,18 @@ def import_geometry_from_ifc_geom(geom_repr: ifcopenshell.entity_instance) -> GE return read_face(geom_repr) elif geom_repr.is_a("IfcShellBasedSurfaceModel"): return shell_based_surface_model(geom_repr) + elif geom_repr.is_a("IfcFaceBasedSurfaceModel"): + return face_based_surface_model(geom_repr) + elif geom_repr.is_a("IfcClosedShell"): + # Bare shell as the representation item (write_shapes emits imported + # ClosedShell/ConnectedFaceSet bodies this way). + return closed_shell(geom_repr) + elif geom_repr.is_a("IfcOpenShell"): + return open_shell(geom_repr) + elif geom_repr.is_a("IfcConnectedFaceSet"): + # A bare connected face set (the member type of a face-based surface model). MUST come after + # its IfcClosedShell / IfcOpenShell subtypes so those keep their specific readers. + return connected_face_set(geom_repr) elif geom_repr.is_a("IfcCurveBoundedPlane"): return curve_bounded_plane(geom_repr) elif geom_repr.is_a("IfcHalfSpaceSolid"): @@ -97,6 +122,10 @@ def import_geometry_from_ifc_geom(geom_repr: ifcopenshell.entity_instance) -> GE # Covers IfcBooleanClippingResult (a subtype). Returns a wrapped Geometry carrying # the cut(s) as bool_operations, not a raw geom — callers handle both (read_shapes). return boolean_result(geom_repr) + elif geom_repr.is_a("IfcMappedItem"): + # Instanced reuse of a mapped representation, placed by a transform. Unwrap + read the + # underlying geometry natively (no OCC kernel, which otherwise builds the mapped body). + return mapped_item(geom_repr) elif geom_repr.is_a("IfcCurve"): # Curve-only body (Curve3D representation) — e.g. a SAT wire body round-tripped # through write_shapes. IfcCurve is the supertype of every concrete curve entity; @@ -108,6 +137,178 @@ def import_geometry_from_ifc_geom(geom_repr: ifcopenshell.entity_instance) -> GE raise NotImplementedError(f"Geometry type {geom_repr.is_a()} not implemented") +def mapped_item(geom_repr: ifcopenshell.entity_instance) -> GEOM: + """IfcMappedItem — reuse of a MappingSource representation placed by a MappingTarget transform. + + Unwrap the mapped representation item(s), read them natively (recurse), and apply the + mapped-item 4x4 (MappingTarget composed with MappingOrigin — via ifcopenshell's pure-Python + ``get_mappeditem_transformation``, no OCC kernel). A single-item source bakes the transform + into the geometry when it is rigid, else carries it as a mesh-level ``Geometry.transforms``. + A *multi-item* mapped representation (e.g. a detailed part exported as several + IfcPolygonalFaceSets) merges its faceted items into one geometry and carries the shared 4x4 as a + mesh-level transform. Non-faceted multi-item sources raise NotImplementedError so the caller + keeps the kernel fallback for that product.""" + import numpy as np + import ifcopenshell.util.placement as _placement + + from ada.geom import Geometry + + items = list(geom_repr.MappingSource.MappedRepresentation.Items) + matrix = _placement.get_mappeditem_transformation(geom_repr) + + if len(items) == 1: + geom = import_geometry_from_ifc_geom(items[0]) + try: + return _transform_geometry(geom, matrix) + except NotImplementedError: + # A non-rigid (scale/shear) transform, or an analytic solid whose parameters can't absorb + # the 4x4, can't be baked into the geometry. Carry it as a mesh-level world transform + # instead (Geometry.transforms) — applied to the tessellated mesh, so it renders natively + # without the OCC kernel fallback. Any geom kind + any affine works this way. + base = geom if isinstance(geom, Geometry) else Geometry(items[0].id(), geom) + base.transforms = [np.asarray(matrix, dtype=float)] + return base + + # Several items under one mapping source: merge the faceted items into a single geometry (one + # Shape carries one Geometry, so we can't emit them separately) and carry the shared mapped 4x4 + # as a mesh-level transform. This renders natively instead of the OCC kernel building the mapped + # multi-item body. _merge_face_sets raises NotImplementedError for non-face-set items. + merged = _merge_face_sets([import_geometry_from_ifc_geom(it) for it in items]) + base = Geometry(geom_repr.id(), merged) + m = np.asarray(matrix, dtype=float) + if not np.allclose(m, np.eye(4), atol=1e-12): + base.transforms = [m] + return base + + +def _merge_face_sets(geoms: list) -> GEOM: + """Merge a list of homogeneous face sets into one, concatenating coordinates and offsetting the + (1-based) vertex indices. Supports all-PolygonalFaceSet or all-TriangulatedFaceSet inputs; any + other/mixed content raises NotImplementedError (the multi-item mapped-item caller then keeps the + kernel fallback).""" + if geoms and all(isinstance(g, geo_su.PolygonalFaceSet) for g in geoms): + coordinates: list = [] + faces: list[list[int]] = [] + offset = 0 + closed = True + for g in geoms: + faces.extend([[i + offset for i in face] for face in g.faces]) + coordinates.extend(g.coordinates) + offset += len(g.coordinates) + closed = closed and g.closed + return geo_su.PolygonalFaceSet(coordinates=coordinates, faces=faces, closed=closed) + if geoms and all(isinstance(g, geo_su.TriangulatedFaceSet) for g in geoms): + coordinates = [] + normals: list = [] + indices: list[int] = [] + offset = 0 + for g in geoms: + indices.extend([i + offset for i in g.indices]) + coordinates.extend(g.coordinates) + normals.extend(g.normals) + offset += len(g.coordinates) + return geo_su.TriangulatedFaceSet(coordinates=coordinates, normals=normals, indices=indices) + kinds = ", ".join(sorted({type(g).__name__ for g in geoms})) or "empty" + raise NotImplementedError(f"multi-item mapped representation with non-mergeable items ({kinds})") + + +def mapped_instance_group(prod_def: ifcopenshell.entity_instance): + """If a product's Body representation is several IfcMappedItems that all reuse the SAME mapping + source (one IfcRepresentationMap instanced N times, e.g. mapped-shape-with-multiple-items), read + the source geometry ONCE and return a single Geometry carrying every instance's 4x4 in + ``transforms`` — rendered natively via mesh-level instancing instead of the multi-item kernel + fallback. Returns None when the pattern doesn't hold (mixed item kinds, differing sources, or a + source that isn't itself a single item).""" + import numpy as np + import ifcopenshell.util.placement as _placement + + from ada.geom import Geometry + + items = [] + for rep in prod_def.Representation.Representations: + if rep.RepresentationIdentifier == "Body": + items.extend(rep.Items) + if len(items) < 2 or not all(i.is_a("IfcMappedItem") for i in items): + return None + src = items[0].MappingSource.MappedRepresentation + if not all(i.MappingSource.MappedRepresentation == src for i in items): + return None + src_items = list(src.Items) + if len(src_items) != 1: + return None + base = import_geometry_from_ifc_geom(src_items[0]) + base = base if isinstance(base, Geometry) else Geometry(src_items[0].id(), base) + base.transforms = [np.asarray(_placement.get_mappeditem_transformation(i), dtype=float) for i in items] + return base + + +def _transform_curve(curve, xf, r_mat, scale): + """Apply a rigid (optionally uniformly-scaled) 4x4 to a curve by transforming its vertices — + Edge / ArcLine / IndexedPolyCurve / PolyLine (the swept-solid directrix forms).""" + from ada.geom import curves as geo_cu + + if isinstance(curve, geo_cu.Edge): + return geo_cu.Edge(xf(curve.start), xf(curve.end)) + if isinstance(curve, geo_cu.ArcLine): + return geo_cu.ArcLine(xf(curve.start), xf(curve.midpoint), xf(curve.end)) + if isinstance(curve, geo_cu.PolyLine): + return geo_cu.PolyLine(points=[xf(p) for p in curve.points]) + if isinstance(curve, geo_cu.IndexedPolyCurve): + return geo_cu.IndexedPolyCurve( + [_transform_curve(s, xf, r_mat, scale) for s in curve.segments], curve.self_intersect + ) + raise NotImplementedError(f"mapped-item transform of directrix {type(curve).__name__}") + + +def _transform_geometry(geom: GEOM, matrix) -> GEOM: + """Apply a mapped-item 4x4 to a native geometry. Identity returns the geometry unchanged + (the common case — the mapped body sits at the source origin and the product's ObjectPlacement + does the placing). Otherwise the transform must be rigid (optionally uniform scale): point-set + geometries transform their coordinates, an IfcSweptDiskSolid transforms its directrix + scales + its radius. Non-rigid transforms and unsupported analytic types raise NotImplementedError.""" + import numpy as np + + from ada.geom import solids as geo_so + from ada.geom import surfaces as geo_su + from ada.geom.direction import Direction + from ada.geom.points import Point + + m = np.asarray(matrix, dtype=float) + if np.allclose(m, np.eye(4), atol=1e-12): + return geom + + rot = m[:3, :3] + rtr = rot.T @ rot + s2 = float(np.trace(rtr)) / 3.0 + if s2 <= 0 or not np.allclose(rtr, s2 * np.eye(3), atol=1e-6): + raise NotImplementedError("non-rigid (shear / non-uniform scale) mapped-item transform") + scale = float(np.sqrt(s2)) + + def xf(p) -> Point: + q = m @ np.array([float(p[0]), float(p[1]), float(p[2]), 1.0]) + return Point(q[0], q[1], q[2]) + + if isinstance(geom, geo_su.PolygonalFaceSet): + return geo_su.PolygonalFaceSet( + coordinates=[xf(p) for p in geom.coordinates], faces=geom.faces, closed=geom.closed + ) + if isinstance(geom, geo_su.TriangulatedFaceSet): + rn = rot / scale # pure rotation for the normals + normals = [Direction(*(rn @ np.asarray([n[0], n[1], n[2]], float))) for n in geom.normals] + return geo_su.TriangulatedFaceSet( + coordinates=[xf(p) for p in geom.coordinates], normals=normals, indices=geom.indices + ) + if isinstance(geom, geo_so.SweptDiskSolid): + return geo_so.SweptDiskSolid( + directrix=_transform_curve(geom.directrix, xf, rot / scale, scale), + radius=geom.radius * scale, + inner_radius=(geom.inner_radius * scale if geom.inner_radius else None), + start_param=geom.start_param, + end_param=geom.end_param, + ) + raise NotImplementedError(f"mapped-item transform of {type(geom).__name__} (kernel fallback)") + + def boolean_result(geom_repr: ifcopenshell.entity_instance) -> "Geometry": """Read an IfcBooleanResult/IfcBooleanClippingResult into a base Geometry with the cut operand(s) attached as bool_operations (applied downstream by apply_geom_booleans). diff --git a/src/ada/cadit/ifc/read/geom/placement.py b/src/ada/cadit/ifc/read/geom/placement.py index 5c2853033..50806b700 100644 --- a/src/ada/cadit/ifc/read/geom/placement.py +++ b/src/ada/cadit/ifc/read/geom/placement.py @@ -1,7 +1,19 @@ +import numpy as np + from ada import Direction, Point +from ada.api.transforms import Placement from ada.geom.placement import Axis1Placement, Axis2Placement3D +def placement_from_ifc_4x4(matrix) -> Placement: + """Build a Placement from an IFC world transform (``get_local_placement`` / a standard 4x4 + ``IfcObjectPlacement``). Now that ``Placement.from_4x4_matrix`` is self-consistent (row-based, + ``from_4x4_matrix(M).get_matrix4x4() == M``), this is a plain wrapper — the historic transpose + workaround is gone. Kept as a named entry point for the IFC readers (and a home for any + future IFC-specific placement handling).""" + return Placement.from_4x4_matrix(np.asarray(matrix, dtype=float)) + + def ifc_direction(ifc_entity) -> Direction: return Direction(ifc_entity.DirectionRatios) diff --git a/src/ada/cadit/ifc/read/geom/solids.py b/src/ada/cadit/ifc/read/geom/solids.py index f8fba40c7..4e26d23e0 100644 --- a/src/ada/cadit/ifc/read/geom/solids.py +++ b/src/ada/cadit/ifc/read/geom/solids.py @@ -1,6 +1,7 @@ import ifcopenshell from ada.geom import solids as geo_so +from ada.geom import surfaces as geo_su from ada.geom.placement import Axis2Placement3D from ada.geom.points import Point @@ -9,6 +10,55 @@ from .surfaces import closed_shell, get_surface +def _distance_along(placement: ifcopenshell.entity_instance) -> float: + """Arc-length distance of an IfcAxis2PlacementLinear along its base curve — the + IfcPointByDistanceExpression.DistanceAlong at its Location.""" + dist = placement.Location.DistanceAlong + return float(dist.wrappedValue if hasattr(dist, "wrappedValue") else dist) + + +def sectioned_solid_horizontal(ifc_entity: ifcopenshell.entity_instance) -> geo_su.TriangulatedFaceSet: + """IfcSectionedSolidHorizontal — a profile swept along a (horizontal alignment) directrix, its + cross-sections placed at distances along it. For a CONSTANT cross-section (all sections equal) + this is a fixed-reference sweep over the directrix range [first, last] cross-section distance; + we evaluate it natively to a triangulated shell (no OCC kernel, which otherwise explodes the + swept solid into thousands of loose faces). Varying cross-sections aren't handled yet + (NotImplementedError -> the caller's kernel fallback).""" + import numpy as np + + from ada.cadit.ngeom._alignment_sweep import sectioned_solid_horizontal_mesh + from ada.geom import curves as geo_cu + + cross = list(ifc_entity.CrossSections) + if any(cs != cross[0] for cs in cross): + raise NotImplementedError("IfcSectionedSolidHorizontal with varying cross-sections not yet native") + + profile = get_surface(cross[0]) + outer = getattr(profile, "outer_curve", None) + # The 2D profile outline (the swept cross-section). IndexedPolyCurve/PolyLine expose their + # vertices directly; a bare curve type without a vertex list can't seed a swept polygon. + if isinstance(outer, geo_cu.IndexedPolyCurve): + pts = outer.get_points() + elif isinstance(outer, geo_cu.PolyLine): + pts = [tuple(p) for p in outer.points] + else: + raise NotImplementedError(f"sectioned-solid profile outline {type(outer).__name__} not supported") + if not pts or len(pts) < 3: + raise NotImplementedError("sectioned-solid profile has no polygon outline") + + directrix = get_curve(ifc_entity.Directrix) + dists = [_distance_along(p) for p in ifc_entity.CrossSectionPositions] + fixed_ref = (0.0, 0.0, 1.0) + coords, tris = sectioned_solid_horizontal_mesh( + directrix, np.asarray(pts, dtype=float)[:, :2], dists[0], dists[-1], fixed_ref=fixed_ref + ) + return geo_su.TriangulatedFaceSet( + coordinates=[Point(float(c[0]), float(c[1]), float(c[2])) for c in coords], + normals=[], + indices=(tris + 1).reshape(-1).tolist(), # IFC face indices are 1-based + ) + + def faceted_brep(ifc_entity: ifcopenshell.entity_instance) -> geo_so.FacetedBrep: # Handles IfcFacetedBrep and the sibling IfcFacetedBrepWithVoids (adds inner void shells). voids = [closed_shell(v) for v in ifc_entity.Voids] if ifc_entity.is_a("IfcFacetedBrepWithVoids") else [] diff --git a/src/ada/cadit/ifc/read/geom/surfaces.py b/src/ada/cadit/ifc/read/geom/surfaces.py index ee489367b..08de2d5fd 100644 --- a/src/ada/cadit/ifc/read/geom/surfaces.py +++ b/src/ada/cadit/ifc/read/geom/surfaces.py @@ -36,6 +36,10 @@ def get_surface(ifc_entity: ifcopenshell.entity_instance) -> geo_su.SURFACE_GEOM return t_shape_profile_def(ifc_entity) elif ifc_entity.is_a("IfcCircleProfileDef"): return circle_profile_def(ifc_entity) + elif ifc_entity.is_a("IfcRoundedRectangleProfileDef"): + # MUST precede IfcRectangleProfileDef — RoundedRectangle is a subtype, so the + # rectangle branch would swallow it and drop RoundingRadius (sharp corners). + return rounded_rectangle_profile_def(ifc_entity) elif ifc_entity.is_a("IfcRectangleProfileDef"): return rectangle_profile_def(ifc_entity) elif ifc_entity.is_a("IfcDerivedProfileDef"): @@ -163,7 +167,8 @@ def triangulated_face_set(ifc_entity: ifcopenshell.entity_instance) -> geo_su.Tr return geo_su.TriangulatedFaceSet( coordinates=[Point(*x) for x in ifc_entity.Coordinates.CoordList], indices=flatten(ifc_entity.CoordIndex), - normals=[Direction(*x) for x in ifc_entity.Normals], + # Normals is OPTIONAL in the schema (flat shading when absent). + normals=[Direction(*x) for x in ifc_entity.Normals or []], ) @@ -186,6 +191,45 @@ def rectangle_profile_def(ifc_entity: ifcopenshell.entity_instance) -> geo_su.Re ) +def rounded_rectangle_profile_def(ifc_entity: ifcopenshell.entity_instance) -> geo_su.SURFACE_GEOM_TYPES: + """IfcRoundedRectangleProfileDef -> a rectangle (centred on the profile origin) with quarter-circle + fillets of RoundingRadius at all four corners, materialised as an ArbitraryProfileDef whose outer + curve is an IndexedPolyCurve of 4 Edges + 4 ArcLines. Falls back to a plain rectangle when the + radius is ~0 (nothing to round). The dropped rounding was what made the bath-csg void read with + sharp interior corners.""" + x_dim = float(ifc_entity.XDim) + y_dim = float(ifc_entity.YDim) + hx, hy = x_dim / 2.0, y_dim / 2.0 + r = float(ifc_entity.RoundingRadius or 0.0) + # A valid fillet can't exceed the half-extents; clamp defensively. + r = min(r, hx, hy) + if r <= 1e-9: + return rectangle_profile_def(ifc_entity) + + import math + + def arc(cx, cy, start, end, ang_mid_deg): + # midpoint = the on-arc point at 45 deg into the corner (arc spans 90 deg) + m = (cx + r * math.cos(math.radians(ang_mid_deg)), cy + r * math.sin(math.radians(ang_mid_deg))) + return geo_cu.ArcLine(list(start), list(m), list(end)) + + segs = [ + geo_cu.Edge([-hx + r, -hy], [hx - r, -hy]), # bottom + arc(hx - r, -hy + r, (hx - r, -hy), (hx, -hy + r), -45), # bottom-right + geo_cu.Edge([hx, -hy + r], [hx, hy - r]), # right + arc(hx - r, hy - r, (hx, hy - r), (hx - r, hy), 45), # top-right + geo_cu.Edge([hx - r, hy], [-hx + r, hy]), # top + arc(-hx + r, hy - r, (-hx + r, hy), (-hx, hy - r), 135), # top-left + geo_cu.Edge([-hx, hy - r], [-hx, -hy + r]), # left + arc(-hx + r, -hy + r, (-hx, -hy + r), (-hx + r, -hy), 225), # bottom-left + ] + return geo_su.ArbitraryProfileDef( + profile_type=geo_su.ProfileType.from_str(ifc_entity.ProfileType), + outer_curve=geo_cu.IndexedPolyCurve(segs), + profile_name=ifc_entity.ProfileName, + ) + + def curve_bounded_plane(ifc_entity: ifcopenshell.entity_instance) -> geo_su.CurveBoundedPlane: from .curves import get_curve @@ -339,3 +383,17 @@ def shell_based_surface_model(ifc_entity: ifcopenshell.entity_instance) -> geo_s raise NotImplementedError(f"{face} is not yet implemented.") return geo_su.ShellBasedSurfaceModel(sbsm_boundary=sbsm_boundary) + + +def connected_face_set(ifc_entity: ifcopenshell.entity_instance) -> geo_su.ConnectedFaceSet: + """IfcConnectedFaceSet — a set of IfcFace (CfsFaces). Same shape as IfcClosed/OpenShell, but the + generic (not necessarily closed) form used inside an IfcFaceBasedSurfaceModel.""" + from ada.cadit.ifc.read.geom.geom_reader import import_geometry_from_ifc_geom + + return geo_su.ConnectedFaceSet([import_geometry_from_ifc_geom(f) for f in ifc_entity.CfsFaces]) + + +def face_based_surface_model(ifc_entity: ifcopenshell.entity_instance) -> geo_su.FaceBasedSurfaceModel: + """IfcFaceBasedSurfaceModel — a set of IfcConnectedFaceSet (FbsmFaces). The face-set sibling of + IfcShellBasedSurfaceModel; the OCC and NGEOM builders already sew/tessellate its face sets.""" + return geo_su.FaceBasedSurfaceModel(fbsm_faces=[connected_face_set(cfs) for cfs in ifc_entity.FbsmFaces]) diff --git a/src/ada/cadit/ifc/read/native_reader.py b/src/ada/cadit/ifc/read/native_reader.py new file mode 100644 index 000000000..d993208d9 --- /dev/null +++ b/src/ada/cadit/ifc/read/native_reader.py @@ -0,0 +1,100 @@ +"""Native IFC reader: parse via adacpp's C++ IfcResolver (IfcNgeomStream), no ifcopenshell/OCC. + +The geometry-shapes counterpart of ``ada.cadit.step.read.native_reader`` for IFC: iterate the native +per-product ``IfcNgeomStream`` (blob + meta = guid/color/transforms/instance_paths) and build the +Assembly's Part/ShapeProxy tree directly — lazy ShapeStore blobs, colour + spatial hierarchy resolved +in C++. This is a geometry-shapes mode (like the STEP product tree): it does NOT reconstruct typed +Beam/Plate/Pipe objects — the ifcopenshell reader remains the typed-object path. +""" + +from __future__ import annotations + +import pathlib +from typing import Iterator + + +def native_adacpp_ifc_available() -> bool: + """True if adacpp's native per-product IFC->NGEOM stream is importable.""" + try: + import adacpp # noqa: F401 + + return hasattr(adacpp.cad, "IfcNgeomStream") + except Exception: + return False + + +def native_stream_read_ifc_blobs(ifc_path: str | pathlib.Path) -> Iterator[tuple]: + """Yield ``(ngeom_blob, gid, color, world_matrices, instance_paths)`` per IFC product WITHOUT + hydrating the geometry — the foundation of the lazy ShapeStore native IFC import. The C++ + ``StepRootMeta`` is shared with the STEP path, so ``decode_step_root_meta`` decodes it; the IFC + GlobalId (``meta.guid``) is preferred as the shape id when present.""" + import adacpp + + from ada.cadit.step.read.native_reader import decode_step_root_meta + + for nbytes, meta in adacpp.cad.IfcNgeomStream(str(ifc_path)): + gid, color, mats, paths = decode_step_root_meta(meta) + gid = meta.guid or gid + yield nbytes, gid, color, mats, paths + + +def native_read_ifc_into(assembly, ifc_path: str | pathlib.Path, *, product_tree: bool = True) -> int: + """Populate ``assembly`` with a Part/ShapeProxy tree from the native IFC reader. Returns the + number of shapes added. Lazy ShapeStore by default (``Config().cad_lazy_shape_store``); the eager + fallback hydrates each blob to a ``Geometry``.""" + from ada.api.shapes import ShapeProxy, ShapeStore + from ada.api.spatial import Part + from ada.config import Config + + # Lazy store retains each product's NGEOM blob (the zero-copy ndarray view the C++ IfcNgeomStream + # yields — no memcpy) and, when cad_shape_store_compress is on, zlib-compresses it in place to cut + # resident memory — same as the STEP native reader (part.py). + store = ShapeStore(compress=Config().cad_shape_store_compress) if Config().cad_lazy_shape_store else None + asm_parts: dict[tuple, Part] = {} + + def _tree_parent(paths): + # Mirror the STEP reader's _tree_parent: intermediate path levels become nested Parts + # (reusing same-name siblings); the last level is the solid's own product (excluded). + path = paths[0] if paths else None + if not path or len(path) <= 1: + return assembly + parent = assembly + name_path: tuple = () + for level in path[:-1]: + pname = (level[1] if level[1] else f"asm_{level[0]}") if isinstance(level, (tuple, list)) else str(level) + name_path += (pname,) + p = asm_parts.get(name_path) + if p is None: + existing = parent._parts.get(pname) + p = existing if existing is not None else parent.add_part(Part(pname)) + asm_parts[name_path] = p + parent = p + return parent + + n = 0 + for i, (blob, gid, color, mats, paths) in enumerate(native_stream_read_ifc_blobs(ifc_path)): + name = gid if gid not in (None, "") else f"{assembly.name}_{i}" + if store is not None: + idx = store.add_blob( + blob, gid=name, color=color, transforms=(mats or None), instance_paths=(paths or None) + ) + shp = ShapeProxy(name, store, idx, color=color) + else: + from ada.api.primitives import Shape + from ada.cadit.ngeom.deserialize import deserialize_geometries + from ada.geom import Geometry + + dec = deserialize_geometries(blob) + if not dec: + continue + shp = Shape( + name, + Geometry( + id=name, geometry=dec[0][1], color=color, transforms=(mats or None), instance_paths=(paths or None) + ), + color=color, + ) + parent = _tree_parent(paths) if product_tree else assembly + parent.add_shape(shp) + n += 1 + return n diff --git a/src/ada/cadit/ifc/read/read_alignment.py b/src/ada/cadit/ifc/read/read_alignment.py new file mode 100644 index 000000000..5d18722c7 --- /dev/null +++ b/src/ada/cadit/ifc/read/read_alignment.py @@ -0,0 +1,122 @@ +"""Import IFC4x3 alignment-family products (IfcAlignment, IfcAlignmentSegment, IfcReferent, ...) +whose geometry is curve representations — 'Axis' (Curve3D), 'FootPrint' (Curve2D) or 'Segment'. + +These carry no Body/solid, so the generic shape importer skips them and the IfcOpenShell geometry +kernel can hang on their IfcCurveSegments (a 9000-iter root-find per sample). Instead we read the +analytic ada.geom curve (IfcSegmentedReferenceCurve / IfcGradientCurve / IfcCompositeCurve / +IfcCurveSegment with line/arc/clothoid/cosine-spiral parents), evaluate it to a sampled 3D polyline +with the kernel-free alignment evaluator (validated to ~1e-6 vs the ifcopenshell oracle), and mint a +Shape carrying that PolyLine — which renders as GL_LINES on every backend (no OCC).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from ada import Shape +from ada.cadit.ifc.read.geom.curves import get_curve +from ada.cadit.ifc.read.read_color import get_product_color +from ada.cadit.ngeom._alignment_sweep import curve_to_polyline +from ada.config import logger +from ada.geom import Geometry +from ada.geom.curves import PolyLine +from ada.geom.points import Point + +if TYPE_CHECKING: + import ifcopenshell + + from ada.cadit.ifc.store import IfcStore + +# Representation identifiers we can evaluate, most-preferred first: the 3D reference curve is the +# primary geometry; the 2D footprint and per-segment bodies are fallbacks. +_CURVE_REP_PRIORITY = ("Axis", "Segment", "FootPrint", "Curve3D", "Reference") + + +def is_alignment_curve_product(product: ifcopenshell.entity_instance) -> bool: + """True if the product carries a curve (Axis/FootPrint/Segment) representation the alignment + reader can evaluate — i.e. an alignment-family product with geometry but no Body.""" + rep = getattr(product, "Representation", None) + if rep is None: + return False + return any(_curve_items(r) for r in rep.Representations) + + +def _dedupe_consecutive(pts, tol: float = 1e-7) -> list[Point]: + """Drop consecutive coincident points (zero-length edges) from a sampled polyline — they arise + at the boundaries between adjacent curve segments and would make wire-based exporters fail.""" + out: list[Point] = [] + for p in pts: + q = Point(float(p[0]), float(p[1]), float(p[2])) + if not out or float(np.linalg.norm(q - out[-1])) > tol: + out.append(q) + return out + + +def _curve_items(representation) -> list: + """The representation's items if it is a curve representation we handle, else [].""" + ident = getattr(representation, "RepresentationIdentifier", None) + if ident not in _CURVE_REP_PRIORITY: + return [] + return list(representation.Items or []) + + +def import_ifc_alignment(product: ifcopenshell.entity_instance, name, ifc_store: IfcStore) -> Shape | None: + """Evaluate the product's preferred curve representation to a Shape carrying a 3D polyline. + + Prefers the 3D 'Axis' reference curve (with cant, for IfcSegmentedReferenceCurve); falls back + to a 'Segment' or 'FootPrint' representation. Returns ``None`` if no representation evaluates + (logged), so the caller can fall through / skip.""" + rep = getattr(product, "Representation", None) + if rep is None: + return None + + # Pick the highest-priority curve representation present. + by_ident = {} + for r in rep.Representations: + if _curve_items(r): + by_ident.setdefault(r.RepresentationIdentifier, r) + chosen = next((by_ident[i] for i in _CURVE_REP_PRIORITY if i in by_ident), None) + if chosen is None: + return None + + points: list[Point] = [] + for item in chosen.Items: + try: + curve = get_curve(item) + pts = curve_to_polyline(curve) + except NotImplementedError as exc: + logger.debug(f"alignment curve {item.is_a()} on {name!r} not evaluable natively ({exc})") + continue + except Exception as exc: # noqa: BLE001 - a single bad item must not drop the whole product + logger.warning(f"alignment curve eval failed for {name!r} ({item.is_a()}): {exc}") + continue + points.extend(_dedupe_consecutive(pts)) + + points = _dedupe_consecutive(points) + if len(points) < 2: + # A curve that collapses to a single point in its own parametric space (e.g. an isolated + # constant vertical/cant segment) has no line to draw — skip it. Dropping degenerate + # polylines also keeps them out of the wire-based STEP/IFC exporters (which raise on + # zero-length edges). + logger.info(f'alignment product "{name}" ({product.is_a()}) produced no evaluable curve geometry') + return None + + color = get_product_color(product, ifc_store.f) + geom = Geometry(product.GlobalId, PolyLine(points=points), color) + shape = Shape( + name, + geom=geom, + guid=product.GlobalId, + ifc_store=ifc_store, + units=ifc_store.assembly.units, + color=color, + opacity=color.opacity if color is not None else 1.0, + ) + # Alignment products carry no IfcStyledItem, so ``color`` is None; the line-render / GLB + # material path reads ``geom.color`` and calls ``.rgb255`` on it. Shape defaults a None color + # to light-gray — mirror that onto the geometry so the mesh material is never None (the solid + # path does the same sync in Shape.solid_geom). + if geom.color is None: + geom.color = shape.color + return shape diff --git a/src/ada/cadit/ifc/read/read_beam_section.py b/src/ada/cadit/ifc/read/read_beam_section.py index c82e1368b..87cb83bff 100644 --- a/src/ada/cadit/ifc/read/read_beam_section.py +++ b/src/ada/cadit/ifc/read/read_beam_section.py @@ -36,6 +36,10 @@ def import_section_from_ifc(profile_def, units=Units.M) -> Section: t_w=profile_def.WebThickness, t_ftop=profile_def.FlangeThickness, t_fbtn=profile_def.FlangeThickness, + # Flange-root fillet radius (optional in IFC). Stored in the otherwise-unused ``r`` + # for I-sections so iprofiles() rounds the four web/flange junctions instead of + # drawing sharp corners. + r=profile_def.FilletRadius, units=units, sec_str=name, ) diff --git a/src/ada/cadit/ifc/read/read_beams.py b/src/ada/cadit/ifc/read/read_beams.py index 8064ef8e3..002c6c873 100644 --- a/src/ada/cadit/ifc/read/read_beams.py +++ b/src/ada/cadit/ifc/read/read_beams.py @@ -12,7 +12,7 @@ from ada.core.vector_utils import calc_yvec, unit_vector from .geom.geom_reader import get_product_definitions -from .geom.placement import axis3d +from .geom.placement import axis3d, placement_from_ifc_4x4 from .read_beam_section import import_section_from_ifc from .read_materials import read_material from .reader_utils import ( @@ -68,21 +68,25 @@ def import_straight_beam(ifc_elem, axis, name, sec, mat, ifc_store: IfcStore) -> raise ValueError("Number of body objects attached to element is not 1") body = bodies[0] - rel_place = Placement.from_axis3d(axis3d(ifc_elem.ObjectPlacement.RelativePlacement)) - - extrude_dir = unit_vector(rel_place.transform_vector(body.position.axis, inverse=True)) - ref_dir = unit_vector(rel_place.transform_vector(body.position.ref_direction)) + extra_opts = {} + obj_placement = ifc_elem.ObjectPlacement + if obj_placement is not None: + # n1/n2 stay in the beam's LOCAL frame (the extrusion's own position/direction — this is + # also where the cardinal-point offset lives, baked into ExtrudedAreaSolid.Position by the + # authoring tool), and the FULL ObjectPlacement world transform becomes the beam placement. + # get_local_placement composes the whole chain, so this covers BOTH a parent chain + # (beam-standard-case.ifc) AND a bare RelativePlacement with no parent + # (beam-varying-cardinal-points.ifc) — the old no-parent path baked rel_place into the + # extrude DIRECTION but left the location + origin untransformed, dropping the placement + # entirely and rendering the beam at the wrong spot. placement_from_ifc_4x4 reconciles the + # column<->row transpose in Placement.get_matrix4x4 so the scene applies the intended matrix. + extra_opts["placement"] = placement_from_ifc_4x4(get_local_placement(obj_placement)) + extrude_dir = unit_vector(body.position.axis) + ref_dir = unit_vector(body.position.ref_direction) p1 = body.position.location local_y = calc_yvec(ref_dir, extrude_dir) p2 = p1 + extrude_dir * body.depth - extra_opts = {} - obj_placement = ifc_elem.ObjectPlacement - if obj_placement.PlacementRelTo: - local_placement = get_local_placement(obj_placement) - place = Placement.from_4x4_matrix(local_placement) - extra_opts["placement"] = place - common = dict( sec=sec, mat=mat, diff --git a/src/ada/cadit/ifc/read/read_ifc.py b/src/ada/cadit/ifc/read/read_ifc.py index 7066d8e58..97897d419 100644 --- a/src/ada/cadit/ifc/read/read_ifc.py +++ b/src/ada/cadit/ifc/read/read_ifc.py @@ -132,6 +132,7 @@ def load_presentation_layers(self): self.ifc_store.assembly.presentation_layers = PresentationLayers(layers) def load_objects(self, data_only=False, elements2part=None): + any_product_imported = False for product in self.ifc_store.f.by_type("IfcProduct"): if product.Representation is None or data_only is True: logger.info(f'Passing product "{product}"') @@ -159,5 +160,62 @@ def load_objects(self, data_only=False, elements2part=None): continue obj.metadata = props + any_product_imported = True add_to_assembly(self.ifc_store.assembly, obj, parent, elements2part) + + # Only when NO placed product carried geometry: a normal model with + # uninstantiated type definitions alongside real products must not + # grow phantom shapes at the origin. + if data_only is False and not any_product_imported: + self.load_instanceless_type_products() + + def load_instanceless_type_products(self): + """Import geometry from type products in a type-library file. + + Type-library files (e.g. the buildingSMART texture samples: a single + IfcBoilerType carrying an IfcTriangulatedFaceSet via a RepresentationMap, + with no IfcProduct placed anywhere) hold real geometry that the per-product + pass never sees — without this it silently renders as an empty scene. + Types WITH occurrences are skipped: their geometry arrives through the + placed products.""" + from ada import Shape + from ada.cadit.ifc.read.geom.geom_reader import import_geometry_from_ifc_geom + from ada.cadit.ifc.read.read_color import get_product_color + from ada.geom import Geometry + + f = self.ifc_store.f + for tp in f.by_type("IfcTypeProduct"): + if not tp.RepresentationMaps: + continue + # IFC4 inverse for "occurrences of this type" is Types (IfcRelDefinesByType); + # IFC2x3 calls it ObjectTypeOf. + rels = getattr(tp, "Types", None) or getattr(tp, "ObjectTypeOf", None) or [] + if any(rel.RelatedObjects for rel in rels): + continue + name = tp.Name or tp.GlobalId + try: + color = get_product_color(tp, f) + except Exception: # noqa: BLE001 - style resolution is best-effort for type maps + color = None + n_found = 0 + for rep_map in tp.RepresentationMaps: + mapped_rep = rep_map.MappedRepresentation + if mapped_rep is None or mapped_rep.RepresentationIdentifier != "Body": + continue + for item in mapped_rep.Items: + try: + res = import_geometry_from_ifc_geom(item) + except NotImplementedError as e: + logger.warning(f'Skipping type-product geometry for "{name}" (#{item.id()}): {e}') + continue + geom = res if isinstance(res, Geometry) else Geometry(tp.GlobalId, res, color) + shape_name = name if n_found == 0 else f"{name}_{n_found}" + # guid only on the first shape — one GlobalId can't be shared. + guid = tp.GlobalId if n_found == 0 else None + self.ifc_store.assembly.add_shape( + Shape(shape_name, geom=geom, guid=guid, color=color, ifc_store=self.ifc_store) + ) + n_found += 1 + if n_found > 0: + logger.info(f'Imported {n_found} geometry item(s) from instance-less type "{name}"') diff --git a/src/ada/cadit/ifc/read/read_physical_objects.py b/src/ada/cadit/ifc/read/read_physical_objects.py index cb780f380..fb6fcdeb1 100644 --- a/src/ada/cadit/ifc/read/read_physical_objects.py +++ b/src/ada/cadit/ifc/read/read_physical_objects.py @@ -28,25 +28,33 @@ def _belongs_to_system(product) -> bool: def import_physical_ifc_elem(product, name, ifc_store: IfcStore): pr_type = product.is_a() + # Typed imports are best-effort: a product the concept importer can't + # express (tessellated body, missing material/axis, unusual profile) must + # still land as a generic Shape via the fall-through below — never be + # dropped. The named exceptions are the expected downgrades (debug); any + # other failure is logged so real importer bugs stay visible. if pr_type in ["IfcBeamStandardCase", "IfcBeam"]: try: return import_ifc_beam(product, name, ifc_store) except (NoIfcAxesAttachedError, UnableToConvertBoolResToBeamException) as e: logger.debug(e) - pass + except Exception as e: # noqa: BLE001 + logger.warning(f'Beam import of "{name}" failed ("{e}"); importing as a generic shape') if pr_type in ["IfcPlateStandardCase", "IfcPlate"]: try: return import_ifc_plate(product, name, ifc_store) except NoIfcAxesAttachedError as e: logger.debug(e) - pass + except Exception as e: # noqa: BLE001 + logger.warning(f'Plate import of "{name}" failed ("{e}"); importing as a generic shape') if pr_type in ["IfcWall", "IfcWallStandardCase"]: try: return import_ifc_wall(product, name, ifc_store) except NoIfcAxesAttachedError as e: logger.debug(e) - pass + except Exception as e: # noqa: BLE001 + logger.warning(f'Wall import of "{name}" failed ("{e}"); importing as a generic shape') if product.is_a("IfcFastener"): return import_ifc_fastener(product, name, ifc_store) @@ -63,12 +71,20 @@ def import_physical_ifc_elem(product, name, ifc_store: IfcStore): return import_pipe_segment(product, name, ifc_store) # Non-physical products (alignment, annotation, grid, positioning) carry only - # curve/axis representations, never body geometry. Importing them as empty Shapes - # pollutes the model and makes downstream exporters choke on a geometry-less shape - # (solid_geom() raises) — and feeding their curves to the IfcOpenShell geometry kernel - # can hang (IfcCurveSegment runs a 9000-iter root find per sample). Skip any product - # that is neither a physical IfcElement nor carries a Body representation. + # curve/axis representations, never body geometry. Feeding their IfcCurveSegments to the + # IfcOpenShell geometry kernel can hang (a 9000-iter root find per sample), so we never do — + # instead the alignment reader evaluates the analytic curve to a polyline natively (no OCC) + # and renders it as GL_LINES. if not product.is_a("IfcElement") and not _has_body_representation(product): + from .read_alignment import import_ifc_alignment, is_alignment_curve_product + + if is_alignment_curve_product(product): + alignment = import_ifc_alignment(product, name, ifc_store) + if alignment is not None: + return alignment + # No evaluable curve geometry (a geometry-less positioning product, an annotation, …): + # importing it as an empty Shape pollutes the model and makes exporters choke + # (solid_geom() raises), so skip it. logger.info(f'skipping non-physical product "{name}" ({product.is_a()})') return None diff --git a/src/ada/cadit/ifc/read/read_shapes.py b/src/ada/cadit/ifc/read/read_shapes.py index 01e580935..b7f9f3a62 100644 --- a/src/ada/cadit/ifc/read/read_shapes.py +++ b/src/ada/cadit/ifc/read/read_shapes.py @@ -7,8 +7,8 @@ from ifcopenshell.util.placement import get_local_placement from ada import Shape -from ada.api.transforms import Placement from ada.cadit.ifc.read.geom.geom_reader import get_product_definitions +from ada.cadit.ifc.read.geom.placement import placement_from_ifc_4x4 from ada.cadit.ifc.read.read_color import get_product_color from ada.config import Config, logger from ada.geom import Geometry @@ -24,23 +24,25 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc geom = None occ_body = None + blob_rec = None if Config().ifc_import_shape_geom or force_geom: - geom, occ_body = _read_shape_geometry(product, color) + geom, occ_body, blob_rec = _read_shape_geometry(product, color, ifc_store) extra_opts = {} - # Only apply the IFC local placement when we keep the native (parametric) geometry, - # which is expressed in the product's local coordinates. The kernel fallback below - # bakes world coordinates into the OCC body (``USE_WORLD_COORDS``), so re-applying - # the placement there would double-transform it. + # Only apply the IFC local placement when we keep the native (parametric) geometry, which is + # expressed in the product's local coordinates. The kernel fallback below bakes world + # coordinates into the OCC body (``USE_WORLD_COORDS``), so re-applying the placement there would + # double-transform it. ``get_local_placement`` returns the full world 4x4 regardless of whether + # the ObjectPlacement is relative (PlacementRelTo chain) or ABSOLUTE — an absolute placement can + # still carry a non-identity rotation (e.g. an IfcBeam that fell through to the shape importer, + # with its extrusion axis rotated into the world frame), so it must be applied too. Gating on + # PlacementRelTo dropped those rotations and rendered the product on the wrong world axis. if occ_body is None: obj_placement = product.ObjectPlacement - if obj_placement is not None and obj_placement.PlacementRelTo: - local_placement = get_local_placement(obj_placement) - extra_opts["placement"] = Placement.from_4x4_matrix(local_placement) + if obj_placement is not None: + extra_opts["placement"] = placement_from_ifc_4x4(get_local_placement(obj_placement)) - shape = Shape( - name, - geom=geom, + common = dict( guid=product.GlobalId, ifc_store=ifc_store, units=ifc_store.assembly.units, @@ -49,6 +51,39 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc **extra_opts, ) + if (geom is not None or blob_rec is not None) and Config().cad_lazy_shape_store: + # Lazy shape store (default on): keep the geometry as one compact blob and + # mint a ShapeProxy that hydrates on demand — large IFC imports stop holding + # every product's ada.geom tree. Python-native geometry pickles losslessly + # (bool_operations, half-space operands, parametric profiles); products the + # Python readers can't resolve keep the adacpp IfcNgeomStream NGEOM buffer + # as-arrived (zero-copy, tessellation fast-path capable). Kernel-fallback + # products (occ_body) stay eager Shapes: their geometry is the transient + # OCC body, and there is nothing heavy retained to avoid. + from ada.api.shapes import ShapeProxy, ShapeStore + + store = getattr(ifc_store, "_lazy_shape_store", None) + if store is None: + store = ShapeStore(compress=Config().cad_shape_store_compress) + ifc_store._lazy_shape_store = store + if geom is not None: + idx = store.add_geometry(geom) + else: + blob, meta = blob_rec + # meta.transforms is the composed world placement (column-major 16-float, + # like the STEP path); a single instance becomes the Shape placement so + # downstream behaves exactly like the eager IFC path (local geometry + + # Placement). Multi-instance products were filtered out at lookup time. + if len(meta.transforms) == 1: + import numpy as np + + mat = np.asarray(meta.transforms[0], dtype=float).reshape(4, 4, order="F") + common["placement"] = placement_from_ifc_4x4(mat) + idx = store.add_blob(blob, gid=product.GlobalId, color=color) + return ShapeProxy(name, store, idx, **common) + + shape = Shape(name, geom=geom, **common) + # Assign the kernel-built OCC body to the transient cache explicitly rather than via # the constructor: the constructor only routes ``geom`` to ``_occ_cache`` when the # *active* backend recognises it as a shape handle, which is false under adacpp (the @@ -60,18 +95,19 @@ def import_ifc_shape(product: ifcopenshell.entity_instance, name, ifc_store: Ifc return shape -def _read_shape_geometry(product: ifcopenshell.entity_instance, color): +def _read_shape_geometry(product: ifcopenshell.entity_instance, color, ifc_store: IfcStore = None): """Resolve a product's body geometry, preferring adapy's native (parametric) reader. - Returns ``(geom, occ_body)``: a native :class:`~ada.geom.Geometry` when every body - item is a type adapy reads natively, else ``(None, occ_body)`` where ``occ_body`` is a - world-placed OCC ``TopoDS`` built by the IfcOpenShell geometry kernel. The kernel + Returns ``(geom, occ_body, blob_rec)``: a native :class:`~ada.geom.Geometry` when + every body item is a type adapy reads natively; else ``blob_rec = (ngeom_buffer, + meta)`` from adacpp's dep-free ``IfcNgeomStream`` when it resolved the product + (B-reps and analytic solids, kernel-free and lazily storable); else ``occ_body``, + a world-placed OCC ``TopoDS`` built by the IfcOpenShell geometry kernel. The kernel fallback is what lets ``ifc.import_shape_geom`` be on by default: any IFC geometry - representation (swept/half-space/mapped/b-spline/...) still imports, just as a faceted - B-rep rather than a parametric one. Returns ``(None, None)`` only when no geometry can - be produced at all (logged).""" + representation still imports, just as a faceted B-rep rather than a parametric one. + ``(None, None, None)`` only when no geometry can be produced at all (logged).""" if product.Representation is None: - return None, None + return None, None, None try: geometries = get_product_definitions(product) @@ -79,17 +115,30 @@ def _read_shape_geometry(product: ifcopenshell.entity_instance, color): logger.debug(f"native IFC geom reader unsupported for {product.is_a()}; using kernel fallback ({e})") geometries = [] + if len(geometries) > 1: + # A product with several Body items (e.g. multiple IfcMappedItem instances) needs ALL of + # them — one Shape carries one geometry, so taking geometries[0] would silently drop the + # rest. When they're all instances of one shared mapping source, fold them into a single + # mesh-instanced Geometry (renders natively); otherwise keep the kernel fallback (the OCC + # kernel builds every item into one compound). + from ada.cadit.ifc.read.geom.geom_reader import mapped_instance_group + + folded = mapped_instance_group(product) + if folded is not None: + geometries = [folded] + else: + logger.debug(f"product {product.is_a()} has {len(geometries)} Body geometries; kernel fallback") + geometries = [] + if geometries: - if len(geometries) > 1: - logger.warning(f"Multiple geometries on product {product}. Choosing geometry @ index=0") geometry = geometries[0] # A boolean result (e.g. IfcBooleanClippingResult) already comes back as a Geometry # carrying its bool_operations — adopt the product's guid/color rather than re-wrap. if isinstance(geometry, Geometry): geometry.id = product.GlobalId geometry.color = color - return geometry, None - return Geometry(product.GlobalId, geometry, color), None + return geometry, None, None + return Geometry(product.GlobalId, geometry, color), None, None # Only fall back to the kernel for products that carry a *Body* representation, i.e. an # actual solid/surface to render. Curve-only products (e.g. IfcAlignmentSegment, whose @@ -99,12 +148,47 @@ def _read_shape_geometry(product: ifcopenshell.entity_instance, color): # break. The native reader already restricts itself to "Body" items, so this keeps the # fallback aligned with it. if not _has_body_representation(product): - return None, None + return None, None, None + + # Between the Python-native readers and the OCC kernel: adacpp's dep-free native IFC + # resolver (advanced/faceted B-reps + analytic solids the Python readers don't cover). + blob_rec = _native_geom_blob(product, ifc_store) + if blob_rec is not None: + return None, None, blob_rec occ_body = _kernel_occ_shape(product) if occ_body is None: logger.warning(f"No geometry could be produced for product {product}") - return None, occ_body + return None, occ_body, None + + +def _native_geom_blob(product: ifcopenshell.entity_instance, ifc_store: IfcStore): + """``(ngeom_buffer, meta)`` for a product from adacpp's ``IfcNgeomStream``, or + ``None``. The whole file is scanned ONCE per store on first need (guid-keyed map; + buffers retained zero-copy as they arrive). Multi-instance (mapped-item) products + are excluded — the lazy Shape path models one placement per Shape.""" + if ifc_store is None or not Config().cad_lazy_shape_store: + return None + path = getattr(ifc_store, "ifc_file_path", None) + if path is None: + return None + cache = getattr(ifc_store, "_native_geom_blobs", None) + if cache is None: + cache = {} + try: + import adacpp + + stream = adacpp.cad.IfcNgeomStream(str(path)) + for blob, meta in stream: + if meta.guid and len(meta.transforms) <= 1: + cache[meta.guid] = (blob, meta) + except (ImportError, AttributeError): + pass # no adacpp / build predates IfcNgeomStream + except Exception as exc: # noqa: BLE001 - a native scan failure must not break the import + logger.warning("native IFC NGEOM scan failed (%s); using the kernel fallback", exc) + cache = {} + ifc_store._native_geom_blobs = cache + return cache.get(product.GlobalId) def _has_body_representation(product: ifcopenshell.entity_instance) -> bool: @@ -172,7 +256,7 @@ def import_ifc_sphere(product: ifcopenshell.entity_instance, name, ifc_store: If extra_opts = {} obj_placement = product.ObjectPlacement if obj_placement is not None and obj_placement.PlacementRelTo: - extra_opts["placement"] = Placement.from_4x4_matrix(get_local_placement(obj_placement)) + extra_opts["placement"] = placement_from_ifc_4x4(get_local_placement(obj_placement)) # A sphere-bodied product marked MassPoint reads back as MassPoint (mass from properties). if product.ObjectType == "MassPoint": diff --git a/src/ada/cadit/ifc/read/reader_utils.py b/src/ada/cadit/ifc/read/reader_utils.py index f69590f02..f92abe19e 100644 --- a/src/ada/cadit/ifc/read/reader_utils.py +++ b/src/ada/cadit/ifc/read/reader_utils.py @@ -18,7 +18,11 @@ def open_ifc(ifc_file_path: Union[str, pathlib.Path, StringIO]): if type(ifc_file_path) is StringIO: return ifcopenshell.file.from_string(str(ifc_file_path.read())) - return ifcopenshell.open(str(ifc_file_path)) + # Transparently inflate a gzip-compressed IFC shipped under a plain .ifc name (see + # ada.cadit.ifc.store.open_ifc_file). + from ada.cadit.ifc.store import open_ifc_file + + return open_ifc_file(ifc_file_path) def get_ifc_property_sets(ifc_elem) -> dict: @@ -141,6 +145,14 @@ def get_org(f, org_id): def add_to_assembly(assembly: Assembly, obj, ifc_parent, elements2part): from ada import Pipe + if ifc_parent is None: + # IFC4x3 alignment-hosted products (e.g. a signal on an + # IfcLinearPlacement) may have no spatial containment relation + # at all — attach to the assembly root instead of crashing. + logger.info(f'No spatial parent for {type(obj)} "{obj.name}". Adding to Assembly root') + assembly.add_shape(obj) + return + pp_name = ifc_parent.Name if pp_name is None: diff --git a/src/ada/cadit/ifc/store.py b/src/ada/cadit/ifc/store.py index 948918127..827f60769 100644 --- a/src/ada/cadit/ifc/store.py +++ b/src/ada/cadit/ifc/store.py @@ -10,8 +10,9 @@ from ada.base.changes import ChangeAction from ada.base.types import GeomRepr +from ada.base.units import Units from ada.cadit.ifc.units_conversion import convert_file_length_units -from ada.cadit.ifc.utils import assembly_to_ifc_file, default_settings, get_unit_type +from ada.cadit.ifc.utils import assembly_to_ifc_file, calculate_unit_scale, default_settings from ada.cadit.ifc.write.write_user import create_owner_history_from_user from ada.config import Config, logger @@ -19,6 +20,22 @@ from ada import Assembly, Section, User from ada.cadit.ifc.read.read_ifc import IfcReader + + +def open_ifc_file(ifc_file_path: str | os.PathLike) -> ifcopenshell.file: + """Open an IFC file, transparently decompressing a gzip-compressed model. Some exporters and + servers ship a gzip'd IFC under a plain ``.ifc`` name; ``ifcopenshell.open`` then fails with + 'Unable to parse IFC SPF header'. Detect the gzip magic (0x1f 0x8b) and load the inflated SPF + text via ``from_string`` instead.""" + import gzip + + path = str(ifc_file_path) + with open(path, "rb") as fh: + is_gzip = fh.read(2) == b"\x1f\x8b" + if is_gzip: + with gzip.open(path, "rt", encoding="utf-8", errors="replace") as fh: + return ifcopenshell.file.from_string(fh.read()) + return ifcopenshell.open(path) from ada.cadit.ifc.write.write_ifc import IfcWriter @@ -67,7 +84,7 @@ def __post_init__(self): if self.ifc_file_path is not None: self.ifc_file_path = pathlib.Path(self.ifc_file_path) if self.ifc_file_path.exists(): - self.f = ifcopenshell.open(str(self.ifc_file_path)) + self.f = open_ifc_file(self.ifc_file_path) elif self.assembly is not None: self.f = assembly_to_ifc_file(self.assembly) self.add_standard_contexts() @@ -148,7 +165,28 @@ def get_context(self, context_id): matches = [x for x in contexts if x.ContextIdentifier == context_id and x.ContextType == "Model"] if len(matches) == 0: - raise ValueError(f'0 IfcGeometry Subcontexts found with "{context_id=}"') + # Imported files don't always carry the standard subcontexts (e.g. + # the buildingSMART type-library samples have '3D'/'2D' model+plan + # contexts and nothing else). Writing into such a store must not + # fail — create the missing subcontext under the model context, + # exactly as add_standard_contexts would for a fresh file. + model_ctxs = [ + x + for x in contexts + if not x.is_a("IfcGeometricRepresentationSubContext") and (x.ContextType or "").upper() == "MODEL" + ] + if not model_ctxs: + raise ValueError(f'0 IfcGeometry Subcontexts found with "{context_id=}"') + target_view = {"Axis": "GRAPH_VIEW"}.get(context_id, "MODEL_VIEW") + sub = self.f.create_entity( + "IfcGeometricRepresentationSubContext", + ContextIdentifier=context_id, + ContextType="Model", + ParentContext=model_ctxs[0], + TargetView=target_view, + ) + self._context_cache[context_id] = sub + return sub if len(matches) > 1: raise ValueError(f'Multiple Subcontexts found with "{context_id=}"') @@ -251,9 +289,14 @@ def load_ifc_content_from_file( if projects and getattr(projects[0], "GlobalId", None): self.assembly._guid = projects[0].GlobalId - unit_type = get_unit_type(self.f) - - if unit_type != self.assembly.units: + # Compare raw length scales rather than mapping to the Units + # enum — files in inches/feet (conversion-based units, e.g. the + # buildingSMART samples at scale 0.0254) have no enum member but + # convert_file_length_units handles them fine via ifcopenshell's + # unit entities. + unit_scale = calculate_unit_scale(self.f) # meters per file length unit + target_scale = 0.001 if self.assembly.units == Units.MM else 1.0 + if abs(unit_scale - target_scale) > 1e-9 * target_scale: self.f = convert_file_length_units(self.f, self.assembly.units) if elements2part is None: @@ -431,7 +474,7 @@ def ifc_obj_from_ifc_file(ifc_file: str | os.PathLike) -> ifcopenshell.file: ifc_file = pathlib.Path(ifc_file).resolve().absolute() if ifc_file.exists() is False: raise FileNotFoundError(f'Unable to find "{ifc_file}"') - return ifcopenshell.open(str(ifc_file)) + return open_ifc_file(ifc_file) @staticmethod def copy_ifc_obj(ifc_file: ifcopenshell.file) -> ifcopenshell.file: diff --git a/src/ada/cadit/ifc/write/geom/surfaces.py b/src/ada/cadit/ifc/write/geom/surfaces.py index 855d0c16a..c2a7ea979 100644 --- a/src/ada/cadit/ifc/write/geom/surfaces.py +++ b/src/ada/cadit/ifc/write/geom/surfaces.py @@ -179,6 +179,27 @@ def polygonal_face_set(pfs: geo_su.PolygonalFaceSet, f: ifcopenshell.file) -> if ) +def triangulated_face_set(tfs: geo_su.TriangulatedFaceSet, f: ifcopenshell.file) -> ifcopenshell.entity_instance: + """Converts a TriangulatedFaceSet back to an IfcTriangulatedFaceSet. + + ``indices`` is stored flat and 1-based (the reader flattens CoordIndex); + IFC wants triples. A kernel build would be both lossy and unsupported for + a bare triangle mesh — this is the 1:1 emit.""" + coordinates = f.create_entity( + "IfcCartesianPointList3D", + CoordList=[(float(p.x), float(p.y), float(p.z)) for p in tfs.coordinates], + ) + idx = [int(i) for i in tfs.indices] + coord_index = [tuple(idx[i : i + 3]) for i in range(0, len(idx), 3)] + normals = [(float(n.x), float(n.y), float(n.z)) for n in tfs.normals] or None + return f.create_entity( + "IfcTriangulatedFaceSet", + Coordinates=coordinates, + Normals=normals, + CoordIndex=coord_index, + ) + + def create_closed_shell(cs: geo_su.ClosedShell, f: ifcopenshell.file) -> ifcopenshell.entity_instance: """Converts a ClosedShell to an IFC representation. diff --git a/src/ada/cadit/ifc/write/native_ifc_writer.py b/src/ada/cadit/ifc/write/native_ifc_writer.py new file mode 100644 index 000000000..5b7453a10 --- /dev/null +++ b/src/ada/cadit/ifc/write/native_ifc_writer.py @@ -0,0 +1,61 @@ +"""Native IFC writer: emit via adacpp's ``blobs_to_ifc`` (C++ ifc_emit), no ifcopenshell/OCC. + +Feeds each shape's NGEOM blob + out-of-band record (colour, world transforms, spatial paths) to the +native emitter, which decodes the blob and writes the analytic IFC solid (IfcExtrudedAreaSolid / +IfcSweptDiskSolid / IfcBooleanResult / IfcAdvancedBrep / ...) + IfcStyledItem + the spatial tree — the +inverse of the native IFC reader, so a native round-trip keeps the CSG analytic (never tessellated). + +Only shapes that carry an NGEOM blob (lazy ShapeProxy) are emittable; others are skipped (the caller +falls back to the ifcopenshell writer). Best paired with ``from_ifc(reader="native")``. +""" + +from __future__ import annotations + +import pathlib + + +def native_ifc_writer_available() -> bool: + """True if adacpp's native NGEOM-blobs->IFC emitter is importable.""" + try: + import adacpp # noqa: F401 + + return hasattr(adacpp.cad, "blobs_to_ifc") + except Exception: + return False + + +def native_write_ifc(assembly, out_path: str | pathlib.Path, schema: str = "IFC4X3_ADD2") -> dict: + """Write ``assembly`` to an IFC file natively. Returns the losslessness audit dict + (solids_in/out, faces_in/out/dropped, drop_reasons). Raises if no shape carries an NGEOM blob.""" + import adacpp + import numpy as np + + from ada.api.shapes import ShapeProxy + from ada.base.units import Units + + blobs: list[bytes] = [] + colors: list[list[float]] = [] + transforms: list[list[list[float]]] = [] + paths: list[list[list[tuple]]] = [] + + for shp in assembly.get_all_physical_objects(): + if not isinstance(shp, ShapeProxy): + continue # native writer needs the raw NGEOM blob (lazy store) + blob = shp.ngeom_blob() + if blob is None: + continue + rec = shp._shape_store.record(shp._store_index) + blobs.append(bytes(blob)) + col = rec.color if rec.color is not None else shp.color + colors.append([float(c) for c in col][:4] if col is not None else [0.0, 0.0, 0.0, -1.0]) + # ShapeRecord transforms are 4x4 (column-major-decoded); flatten back to 16-float column-major. + mats = rec.transforms or [] + transforms.append([list(np.asarray(m, dtype="float32").flatten(order="F")) for m in mats]) + ip = rec.instance_paths or [] + paths.append([[(int(r), str(nm)) for (r, nm) in lvl] for lvl in ip]) + + if not blobs: + raise RuntimeError("native IFC writer: no shape carries an NGEOM blob (need lazy ShapeProxy shapes)") + + unit_scale = 0.001 if assembly.units == Units.MM else 1.0 + return adacpp.cad.blobs_to_ifc(blobs, colors, transforms, paths, str(out_path), schema, unit_scale) diff --git a/src/ada/cadit/ifc/write/stream_ifc.py b/src/ada/cadit/ifc/write/stream_ifc.py index 7ea6ce87d..c60d18b87 100644 --- a/src/ada/cadit/ifc/write/stream_ifc.py +++ b/src/ada/cadit/ifc/write/stream_ifc.py @@ -377,12 +377,22 @@ def _emit(pl) -> None: else: # Part.iter_objects_from_fem builds one element's plate(s) at a time; being # ``detached`` they carry no material back-ref and free as soon as _emit drops - # them, so peak memory stays bounded. + # them, so peak memory stays bounded. Curved plates (the SURFACE/PANEL + # strategies' analytic cylinder/B-spline patches) can't be expressed by the + # hand-authored flat-plate text — they buffer (tens of objects by construction) + # and emit as B-rep products after the flat stream, since the B-rep emitter + # must own the id counter while it runs. + from ada import PlateCurved + + curved_pending: list = [] # (owning part guid, PlateCurved) for part, n_shells in fused: cnt = 0 for pl in part.iter_objects_from_fem( beams=False, plates=True, detached=True, mat_cache=mat_cache, merge_strategy=merge_strategy ): + if isinstance(pl, PlateCurved): + curved_pending.append((part.guid, pl)) + continue _emit(pl) cnt += 1 if cnt % 2000 == 0: @@ -396,6 +406,42 @@ def _emit(pl) -> None: if progress_callback is not None: progress_callback(n_shells, n_shells) + if curved_pending: + from ada.cadit.step.write.stream_step_to_ifc import _IfcBrepEmitter + from ada.geom.surfaces import OpenShell, ShellBasedSurfaceModel + + be = _IfcBrepEmitter(emitter.nid - 1) + be._flush = lambda buf: (out.write("\n".join(buf) + "\n"), buf.clear()) + ident_place = be._emit( + lines, f"IfcLocalPlacement($,#{be._axis2(lines, (0, 0, 0), (0, 0, 1), (1, 0, 0))})" + ) + for part_guid, pl in curved_pending: + try: + shell = ShellBasedSurfaceModel(sbsm_boundary=[OpenShell(cfs_faces=[pl.geom.geometry])]) + brep, rep_type = be.solid(lines, shell) + if brep is None: + skipped += 1 + continue + rep = be._emit(lines, f"IfcShapeRepresentation(#{body_ctx_id},'Body','{rep_type}',(#{brep}))") + pds = be._emit(lines, f"IfcProductDefinitionShape($,$,(#{rep}))") + pid = be._emit( + lines, + f"IfcPlate('{create_guid()}',#{owner_id},{_spf_str(pl.name)},$,$," + f"#{ident_place},#{pds},$,$)", + ) + total += 1 + _record_spatial(part_guid, pid) + mat = getattr(pl, "material", None) + if mat is not None: + _record_material(mat.guid, pid) + except Exception as exc: # noqa: BLE001 — one bad plate shouldn't sink the file + skipped += 1 + logger.warning(f"streaming IFC: skipped curved plate {getattr(pl, 'name', '?')!r}: {exc}") + if lines: + out.write("\n".join(lines) + "\n") + lines.clear() + emitter.nid = be.nid + 1 # continue the trailing-relationship ids past the B-rep entities + # ── trailing relationships (hand-emitted from recorded ids) ── nid = emitter.nid diff --git a/src/ada/cadit/ifc/write/write_shapes.py b/src/ada/cadit/ifc/write/write_shapes.py index 2f4178df6..19172b063 100644 --- a/src/ada/cadit/ifc/write/write_shapes.py +++ b/src/ada/cadit/ifc/write/write_shapes.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING import ada.geom.curves as geo_cu +import ada.geom.solids as geo_so import ada.geom.surfaces as geo_su from ada import ( Boolean, @@ -19,11 +20,13 @@ from ada.base.units import Units from ada.cadit.ifc.utils import add_colour, create_local_placement, tesselate_shape from ada.cadit.ifc.write.geom.curves import indexed_poly_curve, poly_line +from ada.cadit.ifc.write.geom.solids import faceted_brep from ada.cadit.ifc.write.geom.surfaces import ( advanced_face, create_closed_shell, create_half_space_geom, curve_bounded_plane, + triangulated_face_set, ) from ada.cadit.ifc.write.shapes.box import generate_ifc_box_geom from ada.cadit.ifc.write.shapes.cone import generate_ifc_cone_geom @@ -195,6 +198,14 @@ def generate_parametric_solid(shape: Shape | PrimSphere, f): geo_su.AdvancedFace: advanced_face, geo_su.CurveBoundedPlane: curve_bounded_plane, geo_su.ClosedShell: create_closed_shell, + # Bare CONNECTED_FACE_SET: the native NGEOM reader's B-rep root form when the + # closedness promotion couldn't verify the shell (e.g. edges split differently + # across adjacent faces). Same face-set emit as ClosedShell. + geo_su.ConnectedFaceSet: create_closed_shell, + # Mesh-native bodies (IFC tessellated imports): 1:1 emit — the + # tessellation fallback would need a kernel build these kinds don't have. + geo_su.TriangulatedFaceSet: triangulated_face_set, + geo_so.FacetedBrep: faceted_brep, # Curve-only bodies (SAT wire bodies import as bare curve geometry): # emitted as a Curve3D representation instead of failing solid_occ(). geo_cu.Edge: _edge_as_polyline, @@ -204,7 +215,9 @@ def generate_parametric_solid(shape: Shape | PrimSphere, f): BoolHalfSpace: create_half_space_geom, } - if type(shape) is Shape: + from ada.api.shapes import ShapeProxy + + if type(shape) in (Shape, ShapeProxy): param_geo = shape.geom.geometry else: param_geo = shape @@ -226,6 +239,8 @@ def generate_parametric_solid(shape: Shape | PrimSphere, f): geo_su.AdvancedFace: "AdvancedSurface", geo_su.CurveBoundedPlane: "AdvancedSurface", geo_su.ClosedShell: "AdvancedSurface", + geo_su.TriangulatedFaceSet: "Tessellation", + geo_so.FacetedBrep: "Brep", geo_cu.Edge: "Curve3D", geo_cu.IndexedPolyCurve: "Curve3D", geo_cu.PolyLine: "Curve3D", diff --git a/src/ada/cadit/ngeom/_alignment_sweep.py b/src/ada/cadit/ngeom/_alignment_sweep.py index 012f60b4b..aa669947f 100644 --- a/src/ada/cadit/ngeom/_alignment_sweep.py +++ b/src/ada/cadit/ngeom/_alignment_sweep.py @@ -54,8 +54,26 @@ def _fresnel(t: float) -> tuple[float, float]: return S, C -def _parent_eval(curve, p: float) -> tuple[np.ndarray, np.ndarray]: - """Parent curve at arc length ``p`` (its own frame) -> (point2d, unit tangent2d).""" +def _cosine_spiral_theta(curve: geo_cu.CosineSpiral, L: float, s: np.ndarray) -> np.ndarray: + """Heading angle theta(s) = s/A0 + (L/(pi*A1))*sin(pi*s/L) for an IfcCosineSpiral (A1 = + CosineTerm, A0 = ConstantTerm, L = the containing segment length). Validated to ~1e-7 vs the + ifcopenshell oracle on the segmented-reference-curve fixture.""" + A1 = float(curve.cosine_term) + A0 = float(curve.constant_term) if curve.constant_term else None + th = np.zeros_like(s, dtype=float) + if A0: + th = th + s / A0 + if L > 0.0 and A1 != 0.0: + th = th + (L / (math.pi * A1)) * np.sin(math.pi * s / L) + return th + + +def _parent_eval(curve, p: float, seg_length: float | None = None) -> tuple[np.ndarray, np.ndarray]: + """Parent curve at arc length ``p`` (its own frame) -> (point2d, unit tangent2d). + + ``seg_length`` is the length of the containing CurveSegment — required by transcendental + spirals (IfcCosineSpiral) whose heading uses the total length L; ignored by the closed-form + parents (line/arc/clothoid).""" if isinstance(curve, geo_cu.Line): # IfcLine parents in alignments are unit-magnitude direction at the origin. return np.array([p, 0.0]), np.array([1.0, 0.0]) @@ -74,6 +92,18 @@ def _parent_eval(curve, p: float) -> tuple[np.ndarray, np.ndarray]: ph = math.pi * t * t / 2.0 tg = np.array([math.cos(ph), sgn * math.sin(ph)]) return (np.array([scale * C, sgn * scale * S]), tg / np.hypot(*tg)) + if isinstance(curve, geo_cu.CosineSpiral): + # No closed form: integrate (cos theta, sin theta) from 0 to p (theta closed-form). + L = abs(float(seg_length)) if seg_length else 0.0 + n = max(32, int(abs(p) / 0.1) + 1) + xs = np.linspace(0.0, p, n) + th = _cosine_spiral_theta(curve, L, xs) + dx = np.diff(xs) + cx, cy = np.cos(th), np.sin(th) + px = float(np.sum((cx[:-1] + cx[1:]) * 0.5 * dx)) + py = float(np.sum((cy[:-1] + cy[1:]) * 0.5 * dx)) + thp = float(_cosine_spiral_theta(curve, L, np.array([p]))[0]) + return np.array([px, py]), np.array([math.cos(thp), math.sin(thp)]) raise NotImplementedError(f"alignment parent curve {type(curve).__name__}") @@ -93,8 +123,8 @@ def _seg_eval(seg: geo_cu.CurveSegment, local_len: float) -> tuple[np.ndarray, n length = float(seg.segment_length) sgn = 1.0 if length >= 0 else -1.0 p = float(seg.segment_start) + sgn * local_len - P, T = _parent_eval(seg.parent_curve, p) - P0, T0 = _parent_eval(seg.parent_curve, float(seg.segment_start)) + P, T = _parent_eval(seg.parent_curve, p, seg_length=length) + P0, T0 = _parent_eval(seg.parent_curve, float(seg.segment_start), seg_length=length) if sgn < 0: T, T0 = -T, -T0 origin = np.asarray(seg.location, dtype=float)[:2] @@ -108,7 +138,11 @@ def _seg_eval(seg: geo_cu.CurveSegment, local_len: float) -> tuple[np.ndarray, n def _curve_segments(curve) -> list[geo_cu.CurveSegment]: """The CurveSegment list of a CompositeCurve / GradientCurve base (skip zero-length terminators).""" - segs = curve.segments if isinstance(curve, (geo_cu.CompositeCurve, geo_cu.GradientCurve)) else curve + segs = ( + curve.segments + if isinstance(curve, (geo_cu.CompositeCurve, geo_cu.GradientCurve, geo_cu.SegmentedReferenceCurve)) + else curve + ) return [s for s in segs if isinstance(s, geo_cu.CurveSegment) and abs(float(s.segment_length)) > 1e-9] @@ -127,6 +161,177 @@ def _sample_planar(segs: list[geo_cu.CurveSegment], n_per: int) -> tuple[np.ndar return np.array(s_vals), np.array(pts) +def gradient_curve_points_with_s(directrix: geo_cu.GradientCurve, n_per: int = 300) -> tuple[np.ndarray, np.ndarray]: + """(arc length s, 3D polyline) of a GradientCurve: horizontal alignment (base_curve, sampled + per segment) with the vertical gradient's height interpolated over arc length.""" + hsegs = _curve_segments(directrix.base_curve) + s, hxy = _sample_planar(hsegs, n_per) # (s,), (s,2) + + # vertical gradient -> z(s) by interpolation on its (distance, height) samples + vsegs = _curve_segments(directrix) + if vsegs: + _, vdh = _sample_planar(vsegs, max(n_per, 50)) + order = np.argsort(vdh[:, 0]) + z = np.interp(s, vdh[order, 0], vdh[order, 1]) + else: + z = np.zeros_like(s) + + return s, np.column_stack([hxy[:, 0], hxy[:, 1], z]) + + +def gradient_curve_points(directrix: geo_cu.GradientCurve, n_per: int = 300) -> np.ndarray: + """Sampled 3D polyline of a GradientCurve (see gradient_curve_points_with_s). Clothoid + segments have no analytic B-rep form, so exports/sweeps consume this sampling.""" + return gradient_curve_points_with_s(directrix, n_per)[1] + + +def composite_curve_points(curve: geo_cu.CompositeCurve, n_per: int = 300) -> np.ndarray: + """Sampled 3D polyline of a horizontal CompositeCurve (z = 0).""" + _, xy = _sample_planar(_curve_segments(curve), n_per) + return np.column_stack([xy[:, 0], xy[:, 1], np.zeros(len(xy))]) + + +def _cant_offset(seg: geo_cu.CurveSegment, s_rel: np.ndarray) -> np.ndarray | None: + """Vertical superelevation offset of one IfcSegmentedReferenceCurve cant segment at base-curve + arc lengths ``s_rel`` measured from the segment start. For a cosine-spiral cant this is the + closed form ``e0 + (L^2/CosineTerm)*(cos(pi*s/L) - 1)`` where e0 is the vertical component of + the 3D segment placement (validated to machine precision vs the ifcopenshell oracle). Returns + ``None`` for parent kinds not handled analytically (caller keeps the base-curve z).""" + L = abs(float(seg.segment_length)) + loc = np.asarray(seg.location, dtype=float) + e0 = float(loc[1]) if loc.size >= 2 else 0.0 # local-y of the 3D placement -> vertical offset + parent = seg.parent_curve + if isinstance(parent, geo_cu.CosineSpiral) and L > 0.0 and float(parent.cosine_term) != 0.0: + amp = L * L / float(parent.cosine_term) + return e0 + amp * (np.cos(math.pi * s_rel / L) - 1.0) + if isinstance(parent, geo_cu.Line): + # A line-parent cant segment is a constant superelevation (no transition). + return np.full_like(s_rel, e0) + return None + + +def segmented_reference_curve_points(curve: geo_cu.SegmentedReferenceCurve, n_per: int = 300) -> np.ndarray: + """Sampled 3D polyline of an IfcSegmentedReferenceCurve: the base GradientCurve's (x,y,z) with + the cant segments' vertical offset added. The horizontal geometry is the base curve's; only z + is displaced by the superelevation.""" + base = curve.base_curve + if isinstance(base, geo_cu.GradientCurve): + s, pts = gradient_curve_points_with_s(base, n_per) + elif isinstance(base, geo_cu.CompositeCurve): + pts = composite_curve_points(base, n_per) + s, _ = _sample_planar(_curve_segments(base), n_per) + else: + raise NotImplementedError(f"segmented-reference base {type(base).__name__}") + + dz = np.zeros(len(s)) + for seg in _curve_segments(curve): + s0 = float(seg.segment_start) + L = abs(float(seg.segment_length)) + mask = (s >= s0 - 1e-9) & (s <= s0 + L + 1e-9) + off = _cant_offset(seg, s[mask] - s0) + if off is not None: + dz[mask] = off + pts = pts.copy() + pts[:, 2] += dz + return pts + + +def curve_to_polyline(curve, n_per: int = 300) -> np.ndarray: + """Sample any supported alignment curve container to a 3D polyline (N,3). Dispatches over + SegmentedReferenceCurve / GradientCurve / CompositeCurve / a bare CurveSegment.""" + if isinstance(curve, geo_cu.SegmentedReferenceCurve): + return segmented_reference_curve_points(curve, n_per) + if isinstance(curve, geo_cu.GradientCurve): + return gradient_curve_points(curve, n_per) + if isinstance(curve, geo_cu.CompositeCurve): + return composite_curve_points(curve, n_per) + if isinstance(curve, geo_cu.CurveSegment): + _, xy = _sample_planar([curve], n_per) + return np.column_stack([xy[:, 0], xy[:, 1], np.zeros(len(xy))]) + raise NotImplementedError(f"alignment curve container {type(curve).__name__}") + + +def _directrix_points_with_s(curve, n_per: int) -> tuple[np.ndarray, np.ndarray]: + """(arc length s, 3D points) for a sweep directrix — GradientCurve / SegmentedReferenceCurve + (via its base) / CompositeCurve. ``s`` is measured along the base curve, matching the distance + coordinate the IfcSectionedSolidHorizontal cross-section positions are given in.""" + if isinstance(curve, geo_cu.SegmentedReferenceCurve): + curve = curve.base_curve + if isinstance(curve, geo_cu.GradientCurve): + return gradient_curve_points_with_s(curve, n_per) + if isinstance(curve, geo_cu.CompositeCurve): + s, xy = _sample_planar(_curve_segments(curve), n_per) + return s, np.column_stack([xy[:, 0], xy[:, 1], np.zeros(len(xy))]) + raise NotImplementedError(f"sweep directrix {type(curve).__name__}") + + +def sectioned_solid_horizontal_mesh( + directrix, + profile_xy, + start_dist: float, + end_dist: float, + fixed_ref=(0.0, 0.0, 1.0), + n_stations: int = 400, +) -> tuple[np.ndarray, np.ndarray]: + """Triangulated swept solid for an IfcSectionedSolidHorizontal with a CONSTANT cross-section. + + ``profile_xy`` is the (M,2) closed outline (no repeated closing vertex). The profile is swept + along ``directrix`` over arc length ``[start_dist, end_dist]`` with fixed-reference framing + (no Frenet roll): at each station the profile's local x maps to the lateral axis and local y to + the fixed-reference "up" (validated exact against the ifcopenshell oracle bbox). Returns + ``(coords (K,3), tri_indices (T,3) 0-based)`` — a closed shell (side quads + fan end caps), + wound outward (signed-volume checked).""" + prof = np.asarray(profile_xy, dtype=float)[:, :2] + if len(prof) >= 2 and np.allclose(prof[0], prof[-1]): + prof = prof[:-1] # drop the repeated closing vertex + m = len(prof) + if m < 3: + raise NotImplementedError("sectioned solid profile has < 3 outline vertices") + + s, pts = _directrix_points_with_s(directrix, n_per=max(200, n_stations // 2)) + lo, hi = min(start_dist, end_dist), max(start_dist, end_dist) + stations = np.linspace(lo, hi, n_stations) + centers = np.column_stack([np.interp(stations, s, pts[:, k]) for k in range(3)]) + + F = np.asarray(fixed_ref, dtype=float) + F = F / np.linalg.norm(F) + tangent = np.gradient(centers, axis=0) + tangent /= np.linalg.norm(tangent, axis=1, keepdims=True) + lateral = np.cross(tangent, F) + lateral /= np.linalg.norm(lateral, axis=1, keepdims=True) + up = np.cross(lateral, tangent) + up /= np.linalg.norm(up, axis=1, keepdims=True) + + n = len(centers) + rings = ( + centers[:, None, :] + + prof[None, :, 0, None] * lateral[:, None, :] # profile x -> lateral + + prof[None, :, 1, None] * up[:, None, :] # profile y -> fixed-reference up + ) # (n, m, 3) + coords = rings.reshape(-1, 3) + + faces = [] + for i in range(n - 1): + for j in range(m): + a, b = i * m + j, i * m + (j + 1) % m + c, d = (i + 1) * m + j, (i + 1) * m + (j + 1) % m + faces.append((a, b, d)) + faces.append((a, d, c)) + base = (n - 1) * m + for j in range(1, m - 1): # convex fan end caps + faces.append((0, j + 1, j)) + faces.append((base, base + j, base + j + 1)) + tris = np.asarray(faces, dtype=np.int64) + + # Orient outward: a closed shell with inward winding tessellates dark. Flip all triangles when + # the signed volume (divergence theorem over the triangles) is negative. + v0, v1, v2 = coords[tris[:, 0]], coords[tris[:, 1]], coords[tris[:, 2]] + signed_vol = float(np.einsum("ij,ij->i", v0, np.cross(v1, v2)).sum()) / 6.0 + if signed_vol < 0.0: + tris = tris[:, ::-1] + return coords, tris + + def directrix_frames( solid: geo_so.FixedReferenceSweptAreaSolid, n_per: int = 300 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -137,16 +342,7 @@ def directrix_frames( if not isinstance(directrix, geo_cu.GradientCurve): raise NotImplementedError(f"directrix {type(directrix).__name__} (only GradientCurve supported)") - hsegs = _curve_segments(directrix.base_curve) - s, hxy = _sample_planar(hsegs, n_per) # (s,), (s,2) - - # vertical gradient -> z(s) by interpolation on its (distance, height) samples - vsegs = _curve_segments(directrix) - _, vdh = _sample_planar(vsegs, max(n_per, 50)) - order = np.argsort(vdh[:, 0]) - z = np.interp(s, vdh[order, 0], vdh[order, 1]) - - pts = np.column_stack([hxy[:, 0], hxy[:, 1], z]) + pts = gradient_curve_points(directrix, n_per) F = np.asarray(solid.fixed_reference, dtype=float) F = F / np.linalg.norm(F) diff --git a/src/ada/cadit/ngeom/deserialize.py b/src/ada/cadit/ngeom/deserialize.py index 34f12a955..6cb7a5264 100644 --- a/src/ada/cadit/ngeom/deserialize.py +++ b/src/ada/cadit/ngeom/deserialize.py @@ -10,8 +10,10 @@ emits one NGEOM root per solid (a B-rep ``ConnectedFaceSet``), and this rebuilds each as an ``ada.geom`` geometry. Also the round-trip oracle for ``serialize_geometries``. -Swept/CSG solids (extruded/revolved/boolean/sphere primitives) are serialized as synthesized profile -faces; the native STEP path never emits them (STEP solids are explicit B-reps), so those tags raise. +Swept/CSG solids (extruded/revolved/boolean/sphere — tags 50-53) are serialized with a synthesized +profile FACE; they decode back to the ada.geom solid (the profile is rebuilt from the face's local-XY +boundary loops). Only the baked-frame FixedReferenceSweptAreaSolid (tag 54) can't be inverted — its +directrix is gone — so it raises (hydrate that via the adacpp tessellator instead). """ from __future__ import annotations @@ -35,6 +37,13 @@ _CIRCLE, _ELLIPSE, _BSPLINE_CURVE, _TRIMMED_CURVE = 20, 21, 22, 24 _PLANE, _CYLINDER, _CONE, _SPHERE, _TORUS, _BSPLINE_SURFACE = 40, 41, 42, 43, 44, 45 _SURF_LIN_EXTRUSION, _SURF_REVOLUTION = 46, 47 +_EXTRUDED_AREA_SOLID, _REVOLVED_AREA_SOLID, _BOOLEAN_RESULT, _SPHERE_SOLID, _FIXED_REF_SWEPT_SOLID = ( + 50, + 51, + 52, + 53, + 54, +) _EDGE_CURVE, _ORIENTED_EDGE, _EDGE_LOOP, _POLY_LOOP, _FACE_BOUND, _FACE_SURFACE, _CONNECTED_FACE_SET = ( 60, 61, @@ -275,6 +284,110 @@ def _connected_face_set(self, c: _Cur): n = c.i32() return su.ConnectedFaceSet([self.get(i) for i in c.i32a(n)]) + # --- swept / CSG solids (tags 50-54) --- + @staticmethod + def _loop_to_curve(loop) -> cu.IndexedPolyCurve: + """One profile boundary loop (in the profile's local XY plane, z=0) → a closed 2D + IndexedPolyCurve. A ``PolyLoop`` is a straight-edge polygon; an ``EdgeLoop`` restores a + Circle-backed oriented edge as an ``ArcLine`` (fillet round-trip), else a straight ``Edge``.""" + + def p2d(p) -> Point: + return Point(float(p[0]), float(p[1])) + + if isinstance(loop, cu.PolyLoop): + pts = [p2d(p) for p in loop.polygon] + return cu.IndexedPolyCurve([cu.Edge(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))]) + + segs: list = [] + for oe in loop.edge_list: + s, e = p2d(oe.start), p2d(oe.end) + geom = getattr(oe.edge_element, "edge_geometry", None) + if isinstance(geom, cu.Circle): + ctr = np.asarray(geom.position.location, dtype=float)[:2] + a = np.asarray(s, dtype=float) - ctr + b = np.asarray(e, dtype=float) - ctr + bis = a + b # minor-arc midpoint direction (profile fillets are minor arcs) + if np.linalg.norm(bis) < 1e-12: # half-circle: rotate `a` 90° + bis = np.array([-a[1], a[0]]) + mid = ctr + float(geom.radius) * bis / (np.linalg.norm(bis) or 1.0) + segs.append(cu.ArcLine(s, Point(float(mid[0]), float(mid[1])), e)) + else: + segs.append(cu.Edge(s, e)) + return cu.IndexedPolyCurve(segs) + + def _profile_from_face(self, face) -> su.ArbitraryProfileDef: + """Rebuild the swept-area ProfileDef the encoder flattened into a planar FACE — outer bound + first, then any inner (void) bounds.""" + bounds = list(getattr(face, "bounds", None) or []) + if not bounds: + raise NgeomDecodeError("swept solid profile face has no bounds") + return su.ArbitraryProfileDef( + profile_type=su.ProfileType.AREA, + outer_curve=self._loop_to_curve(bounds[0].bound), + inner_curves=[self._loop_to_curve(b.bound) for b in bounds[1:]], + ) + + def _extruded_area_solid(self, c: _Cur): + import ada.geom.solids as so + + profile = self._profile_from_face(self.get(c.i32())) + position = self.get(c.i32()) + direction = Direction(*c.v3()) + depth = c.f64() + return so.ExtrudedAreaSolid( + swept_area=profile, position=position, depth=depth, extruded_direction=direction + ) + + def _revolved_area_solid(self, c: _Cur): + import math + + import ada.geom.solids as so + + profile = self._profile_from_face(self.get(c.i32())) + position = self.get(c.i32()) + axis_local = self.get(c.i32()) # Axis1Placement in the position-LOCAL frame (encoder transform) + angle_rad = c.f64() + # Invert the encoder's world→local axis transform (serialize.revolved_area_solid): rebuild the + # position frame's rotation and map the local axis back to world. angle is stored in RADIANS; + # RevolvedAreaSolid.angle is DEGREES. + xdir = np.asarray(position.ref_direction if position.ref_direction is not None else (1, 0, 0), float) + zdir = np.asarray(position.axis if position.axis is not None else (0, 0, 1), float) + xdir = xdir / (np.linalg.norm(xdir) or 1.0) + zdir = zdir / (np.linalg.norm(zdir) or 1.0) + rot = np.column_stack([xdir, np.cross(zdir, xdir), zdir]) # local→world + origin = np.asarray(position.location, float) + loc_w = rot @ np.asarray(axis_local.location, float) + origin + dir_w = rot @ np.asarray(axis_local.axis, float) + axis = Axis1Placement(Point(*loc_w), Direction(*dir_w)) + return so.RevolvedAreaSolid( + swept_area=profile, position=position, axis=axis, angle=math.degrees(angle_rad) + ) + + def _sphere_solid(self, c: _Cur): + import ada.geom.solids as so + + pos = self.get(c.i32()) + radius = c.f64() + return so.Sphere(center=Point(*pos.location), radius=radius) + + def _boolean_result(self, c: _Cur): + from ada.geom.booleans import BooleanResult, BoolOpEnum + + op = {0: BoolOpEnum.DIFFERENCE, 1: BoolOpEnum.UNION, 2: BoolOpEnum.INTERSECTION}[c.i32()] + first = self.get(c.i32()) + second = self.get(c.i32()) + return BooleanResult(first_operand=first, second_operand=second, operator=op) + + def _fixed_ref_swept(self, c: _Cur): + # tag 54 bakes an alignment sweep into per-station (origin, dir_x, dir_y) frames — the + # analytic directrix is gone, so it cannot be rebuilt as an ada.geom + # FixedReferenceSweptAreaSolid. Consumers that need this must decode via adacpp (which + # tessellates the frames directly); flag it rather than return a wrong solid. + raise NgeomDecodeError( + "FIXED_REF_SWEPT_SOLID (tag 54) is a baked per-station frame field with no invertible " + "directrix — hydrate via the adacpp tessellator, not the Python deserializer" + ) + _DISPATCH = { _PLACEMENT3: _Decoder._placement3, @@ -303,6 +416,11 @@ def _connected_face_set(self, c: _Cur): _FACE_BOUND: _Decoder._face_bound, _FACE_SURFACE: _Decoder._face_surface, _CONNECTED_FACE_SET: _Decoder._connected_face_set, + _EXTRUDED_AREA_SOLID: _Decoder._extruded_area_solid, + _REVOLVED_AREA_SOLID: _Decoder._revolved_area_solid, + _BOOLEAN_RESULT: _Decoder._boolean_result, + _SPHERE_SOLID: _Decoder._sphere_solid, + _FIXED_REF_SWEPT_SOLID: _Decoder._fixed_ref_swept, } @@ -341,6 +459,81 @@ def deserialize_geometries(buffer: bytes) -> list[tuple[str, object]]: return out +def connected_face_set_is_closed(cfs) -> bool: + """Topological closedness of a decoded ``ConnectedFaceSet``: every (undirected) + edge is used exactly twice across the faces' bounds — the manifold-closed-shell + condition. NGEOM tag 66 does not record whether the source shell was closed + (MANIFOLD_SOLID_BREP) or open (SHELL_BASED_SURFACE_MODEL), so the native read + paths use this to restore ``ClosedShell`` parity with the Python stream reader. + + Edges are keyed by VALUE, not object identity — the C++ emitter re-encodes an + edge record per referencing face. Endpoints alone are ambiguous for arcs (two + halves of one circle share both endpoints), so the key adds the underlying + curve's signature and the oriented edge's parameter range. Two uses of the same + key = the edge is interior; any other count = a free or non-manifold edge. A + model whose adjacent faces don't encode identical edge values conservatively + reads as open.""" + from collections import Counter + + edge_use: Counter = Counter() + for f in getattr(cfs, "cfs_faces", []) or []: + for b in getattr(f, "bounds", []) or []: + loop = getattr(b, "bound", None) + edge_list = getattr(loop, "edge_list", None) + if edge_list is not None: + for oe in edge_list: + e = oe.edge_element + a, z = tuple(map(float, e.start)), tuple(map(float, e.end)) + ts, te = getattr(oe, "t_start", None), getattr(oe, "t_end", None) + trange = None if ts is None else (min(ts, te), max(ts, te)) + key = ((a, z) if a <= z else (z, a), _edge_curve_sig(e.edge_geometry), trange) + edge_use[key] += 1 + continue + polygon = getattr(loop, "polygon", None) + if polygon is not None: + pts = [tuple(map(float, p)) for p in polygon] + for i, a in enumerate(pts): + z = pts[(i + 1) % len(pts)] + if a == z: + continue + edge_use[((a, z) if a <= z else (z, a), None, None)] += 1 + if not edge_use: + return False + return all(n == 2 for n in edge_use.values()) + + +def _edge_curve_sig(geom): + """A value signature of an edge's underlying curve, discriminating edges whose + endpoints coincide (arcs of one circle, seam edges). Decoded floats from equal + source records are bit-identical, so exact tuples work as keys.""" + if geom is None: + return None + tname = type(geom).__name__ + pos = getattr(geom, "position", None) + loc = tuple(map(float, pos.location)) if pos is not None else None + if hasattr(geom, "radius"): # Circle / cylinder-ish + return (tname, loc, float(geom.radius)) + if hasattr(geom, "semi_axis1"): # Ellipse + return (tname, loc, float(geom.semi_axis1), float(geom.semi_axis2)) + cps = getattr(geom, "control_points_list", None) + if cps is not None: # B-spline + return (tname, tuple(tuple(map(float, p)) for p in cps)) + pnt = getattr(geom, "pnt", None) + if pnt is not None: # Line + return (tname,) + return (tname, loc) + + +def promote_closed_shell(geometry): + """Return ``ClosedShell(cfs_faces)`` when ``geometry`` is a bare, topologically + closed ``ConnectedFaceSet`` root; otherwise return it unchanged. Restores the + Python stream reader's root form (solid_geom / OCC build / STEP re-emit all key + on ClosedShell vs open shells) for natively-parsed B-reps.""" + if type(geometry) is su.ConnectedFaceSet and connected_face_set_is_closed(geometry): + return su.ClosedShell(cfs_faces=geometry.cfs_faces) + return geometry + + def iter_connected_face_set_faces(buffer: bytes): """If the NGEOM buffer's single root is a ``ConnectedFaceSet`` (a B-rep solid's shell), return ``(rid, n_faces, face_gen)`` where ``face_gen`` yields each diff --git a/src/ada/cadit/ngeom/serialize.py b/src/ada/cadit/ngeom/serialize.py index 419d2a17e..a045de327 100644 --- a/src/ada/cadit/ngeom/serialize.py +++ b/src/ada/cadit/ngeom/serialize.py @@ -24,7 +24,17 @@ def _sample_arc(start, mid, end, n: int = 24) -> list: """Sample a 3-point circular arc (IFC ArcLine) into an ordered polyline start->mid->end. Falls back to the 3 raw points if the points are (near-)collinear.""" - p0, p1, p2 = np.asarray(start, float), np.asarray(mid, float), np.asarray(end, float) + + # A profile's ArcLine (I/T/... section fillet) carries 2D points; np.cross of two 2D vectors is + # a SCALAR, and the circumcenter math below then does np.cross(vec, scalar) -> "array has zero + # dimension". Lift to 3D (z=0) so the cross products stay vector-valued. + def _v3(p): + p = np.asarray(p, float).ravel() + return p if p.shape[0] == 3 else np.array([p[0] if p.shape[0] > 0 else 0.0, + p[1] if p.shape[0] > 1 else 0.0, 0.0]) + + start, mid, end = _v3(start), _v3(mid), _v3(end) + p0, p1, p2 = start, mid, end a, b = p1 - p0, p2 - p0 n_vec = np.cross(a, b) nn = np.linalg.norm(n_vec) @@ -60,6 +70,80 @@ def ang(p): return pts +def _sample_trimmed_curve(tc, n: int = 24) -> list: + """Sample a TrimmedCurve — a conic arc (circle/ellipse) or a line segment — start->end into an + ordered point list. Conic trims are angles in radians (the reader normalized the file's plane + -angle unit); a Cartesian-point trim is projected back to its angle.""" + import ada.geom.curves as _cu + + basis = tc.basis_curve + if isinstance(basis, (_cu.Circle, _cu.Ellipse)): + pos = basis.position + loc = np.asarray(pos.location, float) + ref = np.asarray(pos.ref_direction, float) + ref = ref / (np.linalg.norm(ref) or 1.0) + axis = np.asarray(pos.axis, float) + axis = axis / (np.linalg.norm(axis) or 1.0) + y = np.cross(axis, ref) + a = float(getattr(basis, "radius", None) or basis.semi_axis1) + b = float(getattr(basis, "radius", None) or basis.semi_axis2) + + def _angle(trim): + if isinstance(trim, (int, float)): + return float(trim) + d = np.asarray(trim, float) - loc # Cartesian point -> its parametric angle + return float(np.arctan2((d @ y) / (b or 1.0), (d @ ref) / (a or 1.0))) + + t1, t2 = _angle(tc.trim1), _angle(tc.trim2) + if tc.sense_agreement: + if t2 <= t1: + t2 += 2.0 * np.pi + elif t2 >= t1: + t2 -= 2.0 * np.pi + return [loc + a * np.cos(t) * ref + b * np.sin(t) * y for t in np.linspace(t1, t2, n + 1)] + if isinstance(basis, _cu.Line): + p0 = np.asarray(basis.pnt, float) + d = np.asarray(basis.dir, float) + + def _pt(trim): + return (p0 + float(trim) * d) if isinstance(trim, (int, float)) else np.asarray(trim, float) + + endpoints = [_pt(tc.trim1), _pt(tc.trim2)] + return endpoints if tc.sense_agreement else endpoints[::-1] # False = reversed trim direction + raise _Unsupported(f"trimmed-curve basis {type(basis).__name__}") + + +def _composite_curve_loop_points(cc) -> list: + """Ordered boundary points of an IfcCompositeCurve profile boundary — sample each segment's + parent curve (trimmed conic / line / polyline), honour ``same_sense``, and chain, dropping the + shared join point so the POLY_LOOP closes cleanly.""" + import ada.geom.curves as _cu + + def _p3(x): + x = np.asarray(x, float).ravel() + return np.array([x[0], x[1], x[2] if x.shape[0] > 2 else 0.0]) + + pts: list = [] + for seg in cc.segments: + p = seg.parent_curve + if isinstance(p, _cu.TrimmedCurve): + spts = [_p3(x) for x in _sample_trimmed_curve(p)] + elif isinstance(p, _cu.PolyLine): + spts = [_p3(x) for x in p.points] + elif isinstance(p, _cu.ArcLine): + spts = [_p3(x) for x in _sample_arc(p.start, p.midpoint, p.end)] + else: + raise _Unsupported(f"composite-curve segment {type(p).__name__}") + if not getattr(seg, "same_sense", True): + spts = spts[::-1] + if pts and np.linalg.norm(pts[-1] - spts[0]) < 1e-7: + spts = spts[1:] + pts.extend(spts) + if len(pts) > 1 and np.linalg.norm(pts[0] - pts[-1]) < 1e-7: + pts.pop() + return [(float(p[0]), float(p[1]), float(p[2]) if len(p) > 2 else 0.0) for p in pts] + + # tag catalog (spec §3) _PLACEMENT3 = 1 _PLACEMENT1 = 2 @@ -110,6 +194,7 @@ class _Encoder: def __init__(self): self._records: list[tuple[int, bytes]] = [] self._memo: dict[int, int] = {} # id(obj) -> record index + self._pinned_inferred: list = [] # keep transient inferred planes alive (id()-memo safety) def _add(self, tag: int, payload: bytes) -> int: idx = len(self._records) @@ -352,10 +437,37 @@ def face_bound(self, fb: su.FaceBound) -> int: lp = self.loop(fb.bound) return self._add(_FACE_BOUND, self.i32(lp) + self.i32(1 if fb.orientation else 0)) - def face_surface(self, f: su.FaceSurface) -> int: - surf = self.surface(f.face_surface) + def _infer_plane(self, loop) -> su.Plane: + from ada.geom.placement import Axis2Placement3D + + pts = np.asarray([list(p)[:3] for p in loop.polygon], float) + nrm = np.zeros(3) + for i in range(len(pts)): + nrm += np.cross(pts[i], pts[(i + 1) % len(pts)]) + ln = float(np.linalg.norm(nrm)) + if ln < 1e-12: + raise _Unsupported("degenerate planar face") + axis = nrm / ln + ref = pts[1] - pts[0] + ref = ref - float(np.dot(ref, axis)) * axis + rn = float(np.linalg.norm(ref)) + if rn < 1e-9: + alt = np.array([1.0, 0.0, 0.0]) if abs(axis[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) + ref = np.cross(axis, alt) + rn = float(np.linalg.norm(ref)) + ref = ref / rn + plane = su.Plane(position=Axis2Placement3D(location=pts[0].tolist(), axis=axis.tolist(), ref_direction=ref.tolist())) + # Pin it: surface() memoizes by id(), and a transient inferred plane would be GC'd right + # after — the next face's plane reusing the freed id() then collides in the memo and gets + # the previous plane's record, mis-placing the face. Keep every inferred plane alive. + self._pinned_inferred.append(plane) + return plane + + def face_surface(self, f: su.Face) -> int: + fs = getattr(f, "face_surface", None) + surf = self.surface(fs if fs is not None else self._infer_plane(f.bounds[0].bound)) bounds = [self.face_bound(b) for b in f.bounds] - body = self.i32(surf) + self.i32(1 if f.same_sense else 0) + self.i32(len(bounds)) + body = self.i32(surf) + self.i32(1 if getattr(f, "same_sense", True) else 0) + self.i32(len(bounds)) body += b"".join(self.i32(b) for b in bounds) # 1-2 bounds/face: inline (per-face hot path) return self._add(_FACE_SURFACE, body) @@ -374,16 +486,39 @@ def connected_face_set(self, cfs) -> int: @staticmethod def _loop_points_3d(curve) -> list[tuple[float, float, float]]: """Ordered boundary points of a closed profile curve, lifted to z=0 - (the profile lives in its local XY plane). IndexedPolyCurve / Polyline - expose ``get_points()``; fall back to ``.points``.""" + (the profile lives in its local XY plane). + + For an IndexedPolyCurve, walk the SEGMENTS so ``ArcLine`` fillets are sampled into + arc polylines — ``get_points()`` returns only each segment's start/end, chording every + arc. Chording a concave web/flange fillet fills the whole corner triangle instead of + the smaller arc region, so an IPE profile came out ~4% over-area with straight + chamfers instead of rounded fillets. Polyline / everything else falls back to + ``get_points()`` / ``.points``.""" + import ada.geom.curves as cu + + def _to3(p): + p = list(p) + return (float(p[0]), float(p[1]), float(p[2]) if len(p) > 2 else 0.0) + + if isinstance(curve, cu.CompositeCurve): # trimmed-conic / line segments -> sampled loop + return _composite_curve_loop_points(curve) + + segs = getattr(curve, "segments", None) + if segs and any(isinstance(s, cu.ArcLine) for s in segs): + pts: list[tuple[float, float, float]] = [] + for s in segs: + if isinstance(s, cu.ArcLine): + arc = _sample_arc(s.start, s.midpoint, s.end) + pts.extend(_to3(p) for p in arc[:-1]) # end repeats the next segment's start + else: # Edge / straight segment + pts.append(_to3(s.start)) + return pts + getp = getattr(curve, "get_points", None) raw = getp() if callable(getp) else getattr(curve, "points", None) if raw is None: raise _Unsupported(f"profile curve {type(curve).__name__}") - pts: list[tuple[float, float, float]] = [] - for p in raw: - p = list(p) - pts.append((float(p[0]), float(p[1]), float(p[2]) if len(p) > 2 else 0.0)) + pts = [_to3(p) for p in raw] if len(pts) > 1 and pts[0] == pts[-1]: # POLY_LOOP closes implicitly pts.pop() return pts @@ -485,13 +620,48 @@ def box_solid(self, box) -> int: def revolved_area_solid(self, ras) -> int: """RevolvedAreaSolid -> profile FACE + revolution axis (placement1) + - angle + placement. Decoded by adacpp into a BRepPrimAPI_MakeRevol.""" + angle + placement. Decoded by adacpp into a BRepPrimAPI_MakeRevol. + + Two frame/unit conventions must be reconciled with the NGEOM tessellator: + + * ``RevolvedAreaSolid.angle`` is in DEGREES (the OCC build path converts it + ``angle_deg * pi/180``); the NGEOM field is RADIANS (``_revolve_z`` writes + ``2*pi`` for a full turn). Without the conversion a 87.2deg beam revolve + tessellated as 87.2 RADIANS (~14 turns), splattering the profile. + + * ``tessellate_revolve`` rotates the (local) profile ring about the axis and + THEN transforms by the position frame (``F.to_world(rotate(ring, axo, axd))``) + — i.e. it expects the axis in the POSITION-LOCAL frame (as cylinders/cones + emit it: local +Z at the origin). But ``ras.axis`` is in GLOBAL coordinates + (the convention the OCC build path consumes). Left global, the axis and the + profile's own normal ended up parallel and the sweep collapsed flat. Rotation + commutes with the rigid position transform when the axis is expressed in that + frame, so transform the global axis into position-local here.""" + import math + + import numpy as np + + pos = ras.position + xdir = np.asarray(pos.ref_direction if pos.ref_direction is not None else (1.0, 0.0, 0.0), dtype=float) + zdir = np.asarray(pos.axis if pos.axis is not None else (0.0, 0.0, 1.0), dtype=float) + xdir = xdir / (np.linalg.norm(xdir) or 1.0) + zdir = zdir / (np.linalg.norm(zdir) or 1.0) + ydir = np.cross(zdir, xdir) + rot = np.column_stack([xdir, ydir, zdir]) # local->world; rot.T is world->local + origin = np.asarray(pos.location, dtype=float) + + ax_loc_world = np.asarray(ras.axis.location, dtype=float) + ax_dir_world = np.asarray(ras.axis.axis, dtype=float) + local_loc = rot.T @ (ax_loc_world - origin) + local_dir = rot.T @ ax_dir_world + face = self._profile_face(ras.swept_area) + axis_rec = self._add(_PLACEMENT1, self.v3(tuple(local_loc)) + self.v3(tuple(local_dir))) body = ( self.i32(face) - + self.i32(self.placement3(ras.position)) - + self.i32(self.placement1(ras.axis)) - + self.f64(ras.angle) + + self.i32(self.placement3(pos)) + + self.i32(axis_rec) + + self.f64(math.radians(ras.angle)) ) return self._add(_REVOLVED_AREA_SOLID, body) @@ -515,6 +685,62 @@ def fixed_reference_swept_area_solid(self, frs) -> int: ) return self._add(_FIXED_REF_SWEPT_SOLID, body) + def _sweep_frames(self, directrix): + """Per-station (origin, profile-x, profile-y) frames along a general 3D directrix, using a + rotation-minimising (parallel-transport) frame so the swept profile doesn't twist. profile-x + and profile-y are both perpendicular to the tangent; profile-x x profile-y = tangent.""" + pts = np.asarray(self._loop_points_3d(directrix), float) + if len(pts) < 2: + return pts, np.zeros((0, 3)), np.zeros((0, 3)) + t = np.gradient(pts, axis=0) + tn = np.linalg.norm(t, axis=1, keepdims=True) + t = t / np.where(tn < 1e-12, 1.0, tn) + ref = np.array([0.0, 0.0, 1.0]) if abs(t[0, 2]) < 0.9 else np.array([1.0, 0.0, 0.0]) + dir_x = np.zeros((len(pts), 3)) + x0 = np.cross(ref, t[0]) + dir_x[0] = x0 / (np.linalg.norm(x0) or 1.0) + for i in range(1, len(pts)): + v1, v2 = t[i - 1], t[i] + ax = np.cross(v1, v2) + s = float(np.linalg.norm(ax)) + xp = dir_x[i - 1] + if s < 1e-9: # no bend -> carry the frame + dir_x[i] = xp + else: # Rodrigues-rotate the frame by the tangent turn + ax /= s + ang = np.arctan2(s, float(np.dot(v1, v2))) + dir_x[i] = xp * np.cos(ang) + np.cross(ax, xp) * np.sin(ang) + ax * float(np.dot(ax, xp)) * (1 - np.cos(ang)) + dir_x[i] -= float(np.dot(dir_x[i], t[i])) * t[i] # re-orthogonalise against the tangent + dir_x[i] /= np.linalg.norm(dir_x[i]) or 1.0 + return pts, dir_x, np.cross(t, dir_x) + + def swept_disk_solid(self, sds) -> int: + """IfcSweptDiskSolid (rebar/pipe) -> a circular/annular profile swept along the directrix, + encoded like a FixedReferenceSweptAreaSolid (profile FACE + per-station frames). The + directrix is sampled + parallel-transport-framed here, so any directrix (polyline, arc, + composite) works — not just alignment GradientCurves.""" + from ada.geom.placement import Axis2Placement3D + + o = Axis2Placement3D(location=(0.0, 0.0, 0.0)) + inner = [cu.Circle(position=o, radius=float(sds.inner_radius))] if sds.inner_radius else [] + prof = su.ArbitraryProfileDef( + profile_type=su.ProfileType.AREA, outer_curve=cu.Circle(position=o, radius=float(sds.radius)), + inner_curves=inner, + ) + face = self._profile_face(prof) + origins, dir_x, dir_y = self._sweep_frames(sds.directrix) + if len(origins) < 2: + raise _Unsupported("swept-disk directrix has < 2 stations") + body = ( + self.i32(face) + + self.i32(self.placement3(o)) + + self.i32(len(origins)) + + self._f64_raw(origins.ravel()) + + self._f64_raw(dir_x.ravel()) + + self._f64_raw(dir_y.ravel()) + ) + return self._add(_FIXED_REF_SWEPT_SOLID, body) + def _xz_planar_face(self, pts3d) -> int: """Planar FACE_SURFACE in the local XZ plane (y=0; normal=+Y, ref=+X).""" place = self._add(_PLACEMENT3, self.v3((0.0, 0.0, 0.0)) + self.v3((0.0, 1.0, 0.0)) + self.v3((1.0, 0.0, 0.0))) @@ -608,9 +834,11 @@ def _planar_face_from_loop(self, bound) -> int: return self._add(_FACE_SURFACE, self.i32(plane) + self.i32(1) + self.i32(1) + self.i32(bnd)) def _any_face(self, f) -> int: - # FaceSurface / AdvancedFace -> normal path; bare FaceBound (planar loop, - # no surface — Beam.shell_geom) -> synthesize a planar face. - if hasattr(f, "face_surface") and hasattr(f, "bounds"): + # FaceSurface / AdvancedFace / a plain IfcFace (bounds, but no surface attribute — e.g. an + # IfcFaceBasedSurfaceModel's polyloop faces) all go through face_surface, which infers a + # plane when no surface is carried. A bare FaceBound (planar loop — Beam.shell_geom) -> + # synthesize a planar face. + if hasattr(f, "bounds"): return self.face_surface(f) bound = getattr(f, "bound", None) if bound is not None: @@ -652,6 +880,65 @@ def boolean_result(self, br) -> int: b = self._dispatch(br.second_operand) return self._add(_BOOLEAN_RESULT, self.i32(op) + self.i32(a) + self.i32(b)) + def half_space_box(self, hs, ref_min, ref_max) -> int: + """HalfSpaceSolid -> a finite box (EXTRUDED_AREA_SOLID) on the material side + of the plane, sized to cover the reference bbox — the same lowering adacpp's + native STEP/IFC readers apply (``mk_halfspace``), so the neutral buffer needs + no half-space entity and the boolean evaluates identically everywhere. + ``agreement_flag=True`` keeps the material BELOW the plane (-normal side).""" + import math + + pos = hs.base_surface.position + o, z, x_ref = _frame_vectors(pos) + agree = bool(getattr(hs, "agreement_flag", True)) + hd = tuple(-c for c in z) if agree else z + + c = tuple((mn + mx) / 2.0 for mn, mx in zip(ref_min, ref_max)) + diag = math.sqrt(sum((mx - mn) ** 2 for mn, mx in zip(ref_min, ref_max))) + s = diag * 1.5 + 1e-6 + # project the bbox centre onto the cutting plane -> box origin ON the plane + d = sum(zc * (cc - oc) for zc, cc, oc in zip(z, c, o)) + cp = tuple(cc - zc * d for cc, zc in zip(c, z)) + # frame: local Z = material side; X from the plane frame, orthonormalised + t = x_ref if abs(sum(a * b for a, b in zip(hd, x_ref))) < 0.9 else _perp_of(z, x_ref) + dt = sum(a * b for a, b in zip(hd, t)) + fx = tuple(tc - hc * dt for tc, hc in zip(t, hd)) + n = math.sqrt(sum(v * v for v in fx)) or 1.0 + fx = tuple(v / n for v in fx) + + pts = [(-s, -s, 0.0), (s, -s, 0.0), (s, s, 0.0), (-s, s, 0.0)] + loop = self._add(_POLY_LOOP, self.i32(4) + self._v3_raw(pts)) + bound = self._add(_FACE_BOUND, self.i32(loop) + self.i32(1)) + face = self._planar_face([bound]) + place = self._add(_PLACEMENT3, self.v3(cp) + self.v3(hd) + self.v3(fx)) + body = self.i32(face) + self.i32(place) + self.v3((0.0, 0.0, 1.0)) + self.f64(s) + return self._add(_EXTRUDED_AREA_SOLID, body) + + def _fold_bool_ops(self, base_idx: int, base_geom, bool_ops) -> int: + """Chain a base solid's ``Geometry.bool_operations`` into nested + BOOLEAN_RESULT records (base as first operand, applied in order) so cuts + reach the neutral kernel instead of being dropped with the wrapper.""" + idx = base_idx + bbox = None + for op in bool_ops: + og = op.second_operand + while hasattr(og, "geometry") and hasattr(og, "bool_operations"): + og = og.geometry # unwrap core.Geometry + import ada.geom.surfaces as _su + + if isinstance(og, _su.HalfSpaceSolid): + if bbox is None: + bbox = _loose_bbox(base_geom) + if bbox is None: + raise _Unsupported("HalfSpaceSolid operand without a boundable base solid") + op_idx = self.half_space_box(og, bbox[0], bbox[1]) + else: + op_idx = self._dispatch(og) + op_name = getattr(op.operator, "value", op.operator) + opi = {"DIFFERENCE": 0, "UNION": 1, "INTERSECTION": 2}.get(str(op_name).upper(), 0) + idx = self._add(_BOOLEAN_RESULT, self.i32(opi) + self.i32(idx) + self.i32(op_idx)) + return idx + # --- root + finish ----------------------------------------------------------------- def _dispatch(self, geom) -> int: """Serialize one geometry instance to its record index (used for both @@ -667,6 +954,8 @@ def _dispatch(self, geom) -> int: return self.revolved_area_solid(geom) if isinstance(geom, _so.FixedReferenceSweptAreaSolid): return self.fixed_reference_swept_area_solid(geom) + if isinstance(geom, _so.SweptDiskSolid): + return self.swept_disk_solid(geom) if isinstance(geom, _so.Box): return self.box_solid(geom) if isinstance(geom, _so.Cylinder): @@ -687,11 +976,28 @@ def _dispatch(self, geom) -> int: return self.face_surface(geom) if isinstance(geom, (su.ConnectedFaceSet, su.ClosedShell, su.OpenShell)): return self.connected_face_set(geom) + if isinstance(geom, _so.FacetedBrep): + # Render the outer shell's faces natively (each plain planar Face gets an inferred + # plane in face_surface). Voids (FacetedBrepWithVoids) need a boolean cut not yet in + # the stream path — the outer surface still renders, cavities simply not carved. + return self.connected_face_set(geom.outer) raise _Unsupported(f"geometry {type(geom).__name__}") def root(self, geom) -> int: - """Serialize a top-level geometry instance, returning its record index.""" - return self._dispatch(geom) + """Serialize a top-level geometry instance, returning its record index. + + Accepts a raw ``ada.geom`` type or a ``core.Geometry`` wrapper — the wrapper's + ``bool_operations`` are folded into a BOOLEAN_RESULT chain (previously every + caller stripped the wrapper, silently dropping IFC clipping cuts and API + booleans from the stream-tessellation/export paths).""" + ops = [] + while hasattr(geom, "geometry") and hasattr(geom, "bool_operations"): + ops.extend(geom.bool_operations or []) + geom = geom.geometry + idx = self._dispatch(geom) + if ops: + idx = self._fold_bool_ops(idx, geom, ops) + return idx def finish(self, roots: list[tuple[int, str]]) -> bytes: out = bytearray(b"ADANGEOM") @@ -709,6 +1015,131 @@ class _Unsupported(Exception): pass +def _frame_vectors(pos) -> tuple[tuple, tuple, tuple]: + """(origin, z, x) of an Axis2Placement3D with the usual defaults, as plain tuples.""" + import math + + o = tuple(float(v) for v in pos.location) if pos is not None else (0.0, 0.0, 0.0) + z = tuple(float(v) for v in pos.axis) if pos is not None and pos.axis is not None else (0.0, 0.0, 1.0) + n = math.sqrt(sum(v * v for v in z)) or 1.0 + z = tuple(v / n for v in z) + if pos is not None and getattr(pos, "ref_direction", None) is not None: + x = tuple(float(v) for v in pos.ref_direction) + else: + x = _perp_of(z, (1.0, 0.0, 0.0)) + return o, z, x + + +def _perp_of(z, seed) -> tuple: + """A unit vector perpendicular to ``z``, seeded by ``seed`` (world X/Y fallback).""" + import math + + d = sum(a * b for a, b in zip(z, seed)) + if abs(d) > 0.9: + seed = (0.0, 1.0, 0.0) + d = sum(a * b for a, b in zip(z, seed)) + v = tuple(s - zc * d for s, zc in zip(seed, z)) + n = math.sqrt(sum(c * c for c in v)) or 1.0 + return tuple(c / n for c in v) + + +def _loose_bbox(geom) -> tuple[tuple, tuple] | None: + """Loose world-frame bbox of an ada.geom solid — over-estimate is fine, it only + sizes the finite box a HalfSpaceSolid operand is lowered to (mirrors adacpp's + ``solid_item_bbox``). Pure Python/ada.geom — the neutral path stays OCC-free. + Returns ``((minx,miny,minz), (maxx,maxy,maxz))`` or ``None``.""" + import ada.geom.booleans as _bo + import ada.geom.solids as _so + import ada.geom.surfaces as _su + + while hasattr(geom, "geometry") and hasattr(geom, "bool_operations"): + geom = geom.geometry + + def frame_pts(pos, local_pts): + o, z, x = _frame_vectors(pos) + y = ( + z[1] * x[2] - z[2] * x[1], + z[2] * x[0] - z[0] * x[2], + z[0] * x[1] - z[1] * x[0], + ) + return [tuple(o[i] + x[i] * p[0] + y[i] * p[1] + z[i] * p[2] for i in range(3)) for p in local_pts] + + pts: list[tuple] = [] + if isinstance(geom, _bo.BooleanResult): + return _loose_bbox(geom.first_operand) # the result is contained in operand a + if isinstance(geom, _so.ExtrudedAreaSolid): + profile = geom.swept_area + outer = getattr(profile, "outer_curve", None) + if outer is None: + try: + from ada.api.beams.geom_beams import parametric_profile_to_arbitrary + + outer = parametric_profile_to_arbitrary(profile).outer_curve + except Exception: # noqa: BLE001 + return None + base = _profile_curve_pts(outer) + if base is None: + return None + d = tuple(float(v) * float(geom.depth) for v in geom.extruded_direction) + local = base + [(p[0] + d[0], p[1] + d[1], p[2] + d[2]) for p in base] + pts = frame_pts(geom.position, local) + elif isinstance(geom, _so.RevolvedAreaSolid): + base = _profile_curve_pts(getattr(geom.swept_area, "outer_curve", None)) + if base is None: + return None + r = max((p[0] ** 2 + p[1] ** 2 + p[2] ** 2) ** 0.5 for p in base) + world = frame_pts(geom.position, base) + pts = [tuple(c + s * r for c in p) for p in world for s in (-1.0, 1.0)] + elif isinstance(geom, _so.Box): + x, y, z = float(geom.x_length), float(geom.y_length), float(geom.z_length) + pts = frame_pts(geom.position, [(0, 0, 0), (x, 0, 0), (x, y, 0), (0, y, 0), (0, 0, z), (x, y, z)]) + elif isinstance(geom, _so.Cylinder) or isinstance(geom, _so.Cone): + r = float(getattr(geom, "radius", 0.0) or getattr(geom, "bottom_radius", 0.0)) + h = float(geom.height) + pts = frame_pts(geom.position, [(-r, -r, 0), (r, r, 0), (-r, -r, h), (r, r, h)]) + elif isinstance(geom, _so.Sphere): + c, r = geom.center, float(geom.radius) + pts = [tuple(float(cc) + s * r for cc in c) for s in (-1.0, 1.0)] + else: + faces = getattr(geom, "cfs_faces", None) + if faces is None and hasattr(geom, "outer"): # AdvancedBrep / FacetedBrep + faces = getattr(geom.outer, "cfs_faces", None) + if faces is None and hasattr(geom, "bounds"): # a single face + faces = [geom] + if faces is None: + return None + for f in faces: + for b in getattr(f, "bounds", []) or []: + loop = getattr(b, "bound", None) + for oe in getattr(loop, "edge_list", None) or []: + e = getattr(oe, "edge_element", oe) + pts.append(tuple(float(v) for v in e.start)) + pts.append(tuple(float(v) for v in e.end)) + for p in getattr(loop, "polygon", None) or []: + pts.append(tuple(float(v) for v in p)) + if not pts: + return None + mn = tuple(min(p[i] for p in pts) for i in range(3)) + mx = tuple(max(p[i] for p in pts) for i in range(3)) + return mn, mx + + +def _profile_curve_pts(curve) -> list[tuple[float, float, float]] | None: + """Boundary points of a profile curve (shared with the encoder's poly path); + conics fall back to their bounding square.""" + if curve is None: + return None + r = float(getattr(curve, "radius", 0.0) or getattr(curve, "semi_axis1", 0.0) or 0.0) + if r > 0.0: + pos = getattr(curve, "position", None) + o = tuple(float(v) for v in pos.location) if pos is not None else (0.0, 0.0, 0.0) + return [(o[0] + sx * r, o[1] + sy * r, o[2]) for sx in (-1, 1) for sy in (-1, 1)] + try: + return _Encoder._loop_points_3d(curve) + except _Unsupported: + return None + + def serialize_geometries(items: Iterable[tuple[str, object]]) -> bytes: """Serialize ``(id, geometry)`` pairs into one NGEOM buffer. diff --git a/src/ada/cadit/sat/parser/parser.py b/src/ada/cadit/sat/parser/parser.py index 36c675ef6..45669fd09 100644 --- a/src/ada/cadit/sat/parser/parser.py +++ b/src/ada/cadit/sat/parser/parser.py @@ -51,6 +51,41 @@ ) from ada.config import logger +# Non-numeric trailing flag tokens found in ACIS surface/curve records. +_SAT_FLAG_TOKENS = frozenset( + {"I", "F", "#", "forward", "reversed", "both", "in", "out", "double", "single", "reverse_v", "forward_v"} +) + + +def _numeric_after_header(parts: list[str]) -> list[float]: + """Numeric geometry fields of an ACIS surface/curve record, past the entity header. + + The header is ``$owner $attrib`` — those two bare integers are numeric, so a plain + float-filter over the whole record swallows them and shifts every coordinate by two (a plane then + gets a garbage frame and its face tessellates to a single triangle). Skip up to and including the + 2nd ``$``-reference, then collect floats (dropping trailing flag words). Falls back to the whole + record if the expected two references aren't present. + """ + start = 0 + dollars = 0 + for i, p in enumerate(parts): + if p.startswith("$"): + dollars += 1 + if dollars == 2: + start = i + 1 + break + if dollars < 2: + start = 0 + out: list[float] = [] + for p in parts[start:]: + if p in _SAT_FLAG_TOKENS: + continue + try: + out.append(float(p)) + except ValueError: + continue + return out + class AcisSatParser: """ @@ -555,16 +590,8 @@ def _parse_point(self, index: int, data: str) -> AcisPoint: def _parse_straight_curve(self, index: int, data: str) -> AcisStraightCurve: """Parse straight-curve entity.""" parts = data.split() - # straight-curve format: $attrib origin(x,y,z) direction(x,y,z) I I # - # Skip reference tokens (starting with $) and find numeric values - numeric_values = [] - for part in parts: - if part.startswith("$") or part in ["I", "F", "#"]: - continue - try: - numeric_values.append(float(part)) - except ValueError: - continue + # straight-curve format:
origin(x,y,z) direction(x,y,z) I I # + numeric_values = _numeric_after_header(parts) origin = numeric_values[0:3] if len(numeric_values) >= 3 else [0, 0, 0] direction = numeric_values[3:6] if len(numeric_values) >= 6 else [0, 0, 1] @@ -630,27 +657,12 @@ def _parse_spline_curve_data(self, spline_str: str) -> Optional[AcisSplineCurveD def _parse_plane_surface(self, index: int, data: str) -> AcisPlaneSurface: """Parse plane-surface entity.""" parts = data.split() - # plane-surface format: $attrib origin(x,y,z) normal(x,y,z) u_direction(x,y,z) sense I I I I # - # Skip reference tokens (starting with $) and non-numeric flags - numeric_values = [] - for part in parts: - if part.startswith("$") or part in [ - "I", - "F", - "#", - "forward", - "reversed", - "both", - "in", - "out", - "double", - "single", - ]: - continue - try: - numeric_values.append(float(part)) - except ValueError: - continue + # plane-surface format:
origin(x,y,z) normal(x,y,z) u_direction(x,y,z) sense I I I I # + # The ACIS record header is `$owner -1 -1 $attrib` — those two bare `-1` integers are numeric, + # so a plain float-filter swallowed them into the geometry and shifted origin/normal/u_dir by + # two (planes came out with a garbage frame → the face tessellated to a single triangle). Skip + # past the header (up to and including the 2nd $-reference) before reading the coordinates. + numeric_values = _numeric_after_header(parts) origin = numeric_values[0:3] if len(numeric_values) >= 3 else [0, 0, 0] normal = numeric_values[3:6] if len(numeric_values) >= 6 else [0, 0, 1] diff --git a/src/ada/cadit/step/model_scale.py b/src/ada/cadit/step/model_scale.py new file mode 100644 index 000000000..b11b9dbf9 --- /dev/null +++ b/src/ada/cadit/step/model_scale.py @@ -0,0 +1,61 @@ +"""Estimate a model reference scale (bbox diagonal, world units) for adaptive tessellation. + +The adaptive angular density (see ``ada.cad.registry.stream_tess_adaptive``) relaxes the fine +angular ceiling for curved surfaces small *relative to the model*. That needs a model length +scale. A raw min/max bounding box is unusable: CAD STEP files routinely carry a handful of +far-flung reference/construction points (the crane spans ±500 m of stray points yet its real +geometry is ~13-45 m), which inflate the diagonal ~30x and would coarsen every surface. + +So we use an OUTLIER-ROBUST estimate: sample CARTESIAN_POINT coordinates and take ~2x the 99th +percentile of point magnitude. The scan is bounded (a prefix of the file is enough to capture the +bulk distribution; the rare outliers are exactly what we want the percentile to reject), so this +adds only a short read even on a multi-GB STEP. +""" + +from __future__ import annotations + +import pathlib +import re + +_COORD_RE = re.compile(rb"CARTESIAN_POINT\([^(]*\(\s*([-0-9.eE]+)\s*,\s*([-0-9.eE]+)\s*,\s*([-0-9.eE]+)") + + +def estimate_step_model_scale( + path: str | pathlib.Path, *, budget_bytes: int = 96_000_000, chunks: int = 24, max_samples: int = 600_000 +) -> float: + """Robust model bbox-diagonal estimate for a STEP file, or 0.0 if it can't be determined. + + Samples ``chunks`` evenly-spaced windows across the whole file (CARTESIAN_POINT entities may sit + anywhere — some exporters emit them only in the last ~40% of the file, so a prefix scan misses + them), collecting up to ``max_samples`` point magnitudes within ``budget_bytes`` of total reads. + Returns ``2 * p99(|point|)`` — the p99 rejects stray reference points, doubling turns a radius + into a diameter-like span. 0.0 (adaptive disabled for this file) when too few points are found. + """ + try: + import numpy as np + + p = pathlib.Path(path) + size = p.stat().st_size + except OSError: + return 0.0 + + n_chunks = max(1, chunks) + win = max(1, budget_bytes // n_chunks) + mags: list[float] = [] + with open(p, "rb") as f: + for i in range(n_chunks): + off = 0 if n_chunks == 1 else int((size - win) * i / (n_chunks - 1)) + f.seek(max(0, off)) + data = f.read(win) + for m in _COORD_RE.finditer(data): + try: + x, y, z = float(m.group(1)), float(m.group(2)), float(m.group(3)) + except ValueError: + continue + mags.append((x * x + y * y + z * z) ** 0.5) + if len(mags) >= max_samples: + break + + if len(mags) < 100: + return 0.0 + return float(2.0 * np.percentile(np.asarray(mags), 99.0)) diff --git a/src/ada/cadit/step/native_ifc_to_step.py b/src/ada/cadit/step/native_ifc_to_step.py index 8f9e2ffe3..d1a58f42b 100644 --- a/src/ada/cadit/step/native_ifc_to_step.py +++ b/src/ada/cadit/step/native_ifc_to_step.py @@ -39,7 +39,8 @@ def native_ifc_to_step( if deflection is None: deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) if angular_deg is None: - angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) if on_progress is not None: on_progress("adacpp-native-ifc2step", 0.1) stats = adacpp.cad.stream_ifc_to_step(str(ifc_path), str(out_path), deflection=deflection, angular_deg=angular_deg) diff --git a/src/ada/cadit/step/native_step_to_glb.py b/src/ada/cadit/step/native_step_to_glb.py index 5c63a375d..270f352e9 100644 --- a/src/ada/cadit/step/native_step_to_glb.py +++ b/src/ada/cadit/step/native_step_to_glb.py @@ -59,7 +59,21 @@ def native_step_to_glb( if deflection is None: deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) if angular_deg is None: - angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) + + # Adaptive per-surface angular density is ON BY DEFAULT for STEP->GLB: large curved CAD + # assemblies (crane: 7291 solids, thousands of sub-cm bolts/pins) over-tessellate at a fixed + # fine angle, and the GLB is the transfer-size-sensitive product. We estimate a model reference + # scale so adacpp coarsens tiny features while keeping large surfaces fine. ADA_STREAM_TESS_ + # ADAPTIVE=0/false forces the fixed-angle path (model_scale 0 => angular_deg governs everything). + adaptive_env = os.environ.get("ADA_STREAM_TESS_ADAPTIVE") + adaptive = True if adaptive_env is None else adaptive_env.strip().lower() not in {"0", "false", "no", "off", ""} + model_scale = 0.0 + if adaptive: + from ada.cadit.step.model_scale import estimate_step_model_scale + + model_scale = estimate_step_model_scale(step_path) if num_threads <= 0: # ``num_threads=0`` lets the C++ pick std::thread::hardware_concurrency(), which reports the @@ -79,14 +93,23 @@ def native_step_to_glb( if on_progress is not None: on_progress("adacpp-native", 0.1) - n = adacpp.cad.stream_step_to_glb( - str(step_path), - str(glb_path), - deflection=deflection, - angular_deg=angular_deg, - num_threads=num_threads, - meshopt=meshopt, + glb_kwargs = dict( + deflection=deflection, angular_deg=angular_deg, num_threads=num_threads, meshopt=meshopt ) + # Only forward model_scale to an adacpp build that accepts it (nanobind embeds the signature in + # __doc__); an older extension would raise on the unknown kwarg. Off (0.0) behaves as before. + if "model_scale" in (adacpp.cad.stream_step_to_glb.__doc__ or ""): + glb_kwargs["model_scale"] = model_scale + elif model_scale > 0.0: + logger.warning("adacpp build predates adaptive tessellation (no model_scale); using fixed angular_deg") + # Opt-in per-face clickable regions (scenes[0].extras face_ranges_node): ADA_STREAM_TESS_FACE_REGIONS=1. + # Off by default — it bloats the GLB and forces serial face tessellation. Only forward to an adacpp + # build whose binding accepts it (older extensions would raise on the unknown kwarg). + fr_env = os.environ.get("ADA_STREAM_TESS_FACE_REGIONS") + face_regions = fr_env is not None and fr_env.strip().lower() not in {"0", "false", "no", "off", ""} + if face_regions and "face_regions" in (adacpp.cad.stream_step_to_glb.__doc__ or ""): + glb_kwargs["face_regions"] = True + n = adacpp.cad.stream_step_to_glb(str(step_path), str(glb_path), **glb_kwargs) if n < 0: raise RuntimeError(f"adacpp native stream_step_to_glb failed for {step_path}") @@ -96,7 +119,8 @@ def native_step_to_glb( # capture (the worker adds wall-time + peak RSS to the row's metrics separately). print( f"[adacpp-native] {n} solids -> {glb_path} " - f"(threads={num_threads}, deflection={deflection}, angular={angular_deg}, meshopt={meshopt})", + f"(threads={num_threads}, deflection={deflection}, angular={angular_deg}, meshopt={meshopt}, " + f"adaptive={'off' if model_scale <= 0 else f'model_scale={model_scale:.0f}'})", flush=True, ) if on_progress is not None: diff --git a/src/ada/cadit/step/native_step_to_ifc.py b/src/ada/cadit/step/native_step_to_ifc.py index 32326cb49..349b8c82b 100644 --- a/src/ada/cadit/step/native_step_to_ifc.py +++ b/src/ada/cadit/step/native_step_to_ifc.py @@ -55,7 +55,8 @@ def native_step_to_ifc( if deflection is None: deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) if angular_deg is None: - angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) if num_threads <= 0: # Bound to the cgroup-aware allotment (not the node's core count) so we don't oversubscribe a # CPU-capped pod — same rule as the native GLB/mesh paths. diff --git a/src/ada/cadit/step/native_step_to_mesh.py b/src/ada/cadit/step/native_step_to_mesh.py index d2dda891d..54110e5c2 100644 --- a/src/ada/cadit/step/native_step_to_mesh.py +++ b/src/ada/cadit/step/native_step_to_mesh.py @@ -53,7 +53,8 @@ def native_step_to_mesh( if deflection is None: deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) if angular_deg is None: - angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) if num_threads <= 0: # Mirror the native GLB path: bound threads to the cgroup-aware allotment, not the node's # core count, so we don't oversubscribe a CPU-capped pod or bloat per-thread malloc arenas. @@ -64,13 +65,33 @@ def native_step_to_mesh( except Exception: # noqa: BLE001 num_threads = 0 + # Adaptive per-surface density is ON BY DEFAULT for STEP->OBJ/STL too (same rationale as + # STEP->GLB: dense curved assemblies over-tessellate, and the text OBJ/STL are the largest, + # slowest products — the crane's 107M-tri OBJ/STL blew the 5-min timeout). ADA_STREAM_TESS_ + # ADAPTIVE=0/false forces the fixed-angle path. + adaptive_env = os.environ.get("ADA_STREAM_TESS_ADAPTIVE") + adaptive = True if adaptive_env is None else adaptive_env.strip().lower() not in {"0", "false", "no", "off", ""} + model_scale = 0.0 + if adaptive: + from ada.cadit.step.model_scale import estimate_step_model_scale + + model_scale = estimate_step_model_scale(step_path) + if on_progress is not None: on_progress("adacpp-native-mesh", 0.1) - n = adacpp.cad.stream_step_to_mesh( - str(step_path), str(out_path), fmt, deflection=deflection, angular_deg=angular_deg, num_threads=num_threads - ) + mesh_kwargs = dict(deflection=deflection, angular_deg=angular_deg, num_threads=num_threads) + if "model_scale" in (adacpp.cad.stream_step_to_mesh.__doc__ or ""): + mesh_kwargs["model_scale"] = model_scale + elif model_scale > 0.0: + logger.warning("adacpp build predates adaptive tessellation (no model_scale); using fixed angular_deg") + n = adacpp.cad.stream_step_to_mesh(str(step_path), str(out_path), fmt, **mesh_kwargs) if n < 0: raise RuntimeError(f"adacpp native stream_step_to_mesh failed for {step_path}") + # Record the triangle tally for the audit (convert_meta["tri_stats"]) so a tessellation-output + # change (density drift, the adaptive thread bug, a dropped solid) is caught run-to-run. + from ada.cadit.step.tess_stats import record_tri_stats + + record_tri_stats(n_tris=n, engine="adacpp:libtess2") logger.info("adacpp-native STEP->%s: %s triangles -> %s", fmt.upper(), n, out_path) print( f"[adacpp-native] {n} triangles -> {out_path} (fmt={fmt}, threads={num_threads}, " diff --git a/src/ada/cadit/step/native_step_to_step.py b/src/ada/cadit/step/native_step_to_step.py index 4c5ce22c4..5f6a6252d 100644 --- a/src/ada/cadit/step/native_step_to_step.py +++ b/src/ada/cadit/step/native_step_to_step.py @@ -40,7 +40,8 @@ def native_step_to_step( if deflection is None: deflection = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) if angular_deg is None: - angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + angular_deg = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) if num_threads <= 0: try: from ada.visit.scene_handling.scene_from_step_stream import _stream_workers diff --git a/src/ada/cadit/step/read/native_reader.py b/src/ada/cadit/step/read/native_reader.py index e9454ebe1..4bc4de9ae 100644 --- a/src/ada/cadit/step/read/native_reader.py +++ b/src/ada/cadit/step/read/native_reader.py @@ -30,21 +30,38 @@ def native_adacpp_step_available() -> bool: return False +def native_stream_read_step_blobs(step_path: str | pathlib.Path) -> Iterator[tuple]: + """Yield ``(ngeom_blob, gid, color, world_matrices, instance_paths)`` per solid WITHOUT + hydrating the geometry — the foundation of the lazy ``ShapeStore`` import path. + + The blob is yielded exactly as it crosses the adacpp boundary (today ``bytes``; a + buffer view once the zero-copy binding lands) so the consumer can retain it with + no further copies. Hydration, when a consumer eventually wants the ``ada.geom`` + tree, is ``deserialize_geometries(blob)``.""" + import adacpp + + for nbytes, meta in adacpp.cad.StepNgeomStream(str(step_path)): + gid, color, mats, paths = decode_step_root_meta(meta) + yield nbytes, gid, color, mats, paths + + def native_stream_read_step(step_path: str | pathlib.Path) -> Iterator[Geometry]: """Yield one ``ada.geom.Geometry`` per solid, parsed natively — the inverse-serialized B-rep plus its colour, world-placement matrices and assembly paths. A drop-in for ``stream_read_step``. Uses the streaming ``StepNgeomStream`` iterator (one solid's NGEOM buffer at a time, bounded memory), so it scales to large assemblies instead of materialising the whole parsed model.""" - import adacpp - from ada.cadit.ngeom.deserialize import deserialize_geometries - for nbytes, meta in adacpp.cad.StepNgeomStream(str(step_path)): + for nbytes, gid, color, mats, paths in native_stream_read_step_blobs(step_path): decoded = deserialize_geometries(nbytes) # exactly one root per streamed buffer if not decoded: continue - gid, color, mats, paths = decode_step_root_meta(meta) + # Roots are yielded as decoded (bare ConnectedFaceSet). The ClosedShell + # promotion (edge-pairing closedness check) costs ~17% of this loop on a + # large B-rep assembly and only matters where shells are re-exported or + # solid-built — the Assembly import paths apply promote_closed_shell at + # wrap/hydration; the streaming exporters (obj/stl/step emit) don't need it. yield Geometry( id=gid, geometry=decoded[0][1], color=color, transforms=(mats or None), instance_paths=(paths or None) ) diff --git a/src/ada/cadit/step/read/stream_reader.py b/src/ada/cadit/step/read/stream_reader.py index 374d21bac..444a1af52 100644 --- a/src/ada/cadit/step/read/stream_reader.py +++ b/src/ada/cadit/step/read/stream_reader.py @@ -1776,6 +1776,12 @@ def _b_brep_with_voids(r: _Resolver, a: list) -> ClosedShell: "COMPLEX_TRIANGULATED_FACE_SET": _b_triangulated_face_set, "TESSELLATED_SHELL": _b_tessellated_shell, "TESSELLATED_SOLID": _b_tessellated_shell, + # Wireframe bodies (GEOMETRICALLY_BOUNDED_WIREFRAME_SHAPE_REPRESENTATION + # items): loose curves are renderable line geometry — without these entries + # a wire-only STEP (e.g. adapy's own GEOMETRIC_CURVE_SET export of a + # sectionless SAT wire body) reads back as zero roots. + "GEOMETRIC_CURVE_SET": _b_geometric_set, + "GEOMETRIC_SET": _b_geometric_set, } diff --git a/src/ada/cadit/step/tess_stats.py b/src/ada/cadit/step/tess_stats.py new file mode 100644 index 000000000..b5e0f18dc --- /dev/null +++ b/src/ada/cadit/step/tess_stats.py @@ -0,0 +1,109 @@ +"""Per-conversion triangle tallies, folded into the audit ``convert_meta`` so a +run-to-run change in tessellation output (a regression, a density-toggle drift, a +silently-dropped solid) is visible without re-downloading and re-parsing the artefact. + +Same mechanism as the OCC mesh-health / tess-fallback tallies (``ada.occ.tessellating``): +the conversion records a compact stats dict here; ``subprocess_convert`` consumes it at +job completion and emits a ``[TRISTATS-JSON]`` marker line the worker parent folds into +``audit_log.convert_meta["tri_stats"]``. + +Schema (all keys optional except ``n_tris``): + { + "n_tris": , # total output triangles + "engine": "adacpp:libtess2" | "adacpp:occ" | ..., + "n_primitives": , # GLB: draw primitives (material-merged, not solids) + "n_solids": , # mesh/GLB: source solids/products when known + "max_tris_per_solid": , # heaviest single solid, when known + } +The primary regression signal is ``n_tris`` (e.g. the adaptive-density thread bug that +doubled a monster solid's triangles would show up here immediately); ``n_solids`` catches +"no geometry left behind" drops. +""" + +from __future__ import annotations + +import json +import struct +import threading + +_LOCK = threading.Lock() +_STATS: dict = {} + + +def record_tri_stats( + *, + n_tris: int, + engine: str | None = None, + n_solids: int | None = None, + n_primitives: int | None = None, + max_tris_per_solid: int | None = None, +) -> None: + """Record this conversion's triangle tally (last writer wins — one conversion per child).""" + with _LOCK: + _STATS.clear() + _STATS["n_tris"] = int(n_tris) + if engine: + _STATS["engine"] = str(engine) + if n_solids is not None: + _STATS["n_solids"] = int(n_solids) + if n_primitives is not None: + _STATS["n_primitives"] = int(n_primitives) + if max_tris_per_solid is not None: + _STATS["max_tris_per_solid"] = int(max_tris_per_solid) + + +def consume_tri_stats() -> dict: + """Return and clear the recorded stats (empty dict if none recorded).""" + with _LOCK: + s = dict(_STATS) + _STATS.clear() + return s + + +# Triangle-primitive modes in glTF (4=TRIANGLES, 5=TRIANGLE_STRIP, 6=TRIANGLE_FAN). Default is 4. +_TRI_MODES = {4, 5, 6} + + +def count_glb_tri_stats(path) -> dict: + """Total triangles + primitive count of a .glb, parsing ONLY the JSON chunk (no vertex data). + + adacpp merges solids by material, so a GLB primitive is a material batch, not a source solid — + hence ``n_primitives`` (not ``n_solids``). Returns ``{}`` on any parse failure (best-effort). + """ + try: + with open(path, "rb") as f: + head = f.read(12) + if len(head) < 12 or head[:4] != b"glTF": + return {} + # First chunk after the 12-byte header must be the JSON chunk. + chunk_hdr = f.read(8) + if len(chunk_hdr) < 8: + return {} + clen, ctype = struct.unpack(" bool: return native_adacpp_step_available() +# Loose curve/geometric-set roots (wireframe bodies — SAT wire bodies, evaluated alignment +# reference curves) that the native adacpp reader (solid-only) silently drops. "auto" must stay +# lossless, so a file carrying these routes to the pure-Python reader instead. +_CURVE_SET_MARKERS = (b"GEOMETRIC_CURVE_SET", b"GEOMETRIC_SET") + + +def step_has_curve_set_roots(src_path: str | pathlib.Path, size_limit: int = 64_000_000) -> bool: + """Cheap check: does the STEP file contain loose curve/geometric-set roots that the native + (solid-only) reader would drop? Bounded to files under ``size_limit`` — such wireframe bodies + are small, and a multi-GB solid assembly is not worth a full extra scan (kept on the fast + native path).""" + try: + p = pathlib.Path(src_path) + if p.stat().st_size > size_limit: + return False + data = p.read_bytes() + except OSError: + return False + return any(m in data for m in _CURVE_SET_MARKERS) + + def _python_solids(src_path) -> Iterator[Geometry]: # local_pool=False: random-access two-pass index (valid for any reference order); # tolerant skips unsupported solids rather than raising. @@ -43,6 +64,12 @@ def read_solids(src_path: str | pathlib.Path) -> Iterator[Geometry]: yield from _python_solids(src_path) return + # The native reader is solid-only; a file with loose curve/geometric-set roots (wireframe + # bodies) would silently lose them, so route it to the lossless pure-Python reader. + if step_has_curve_set_roots(src_path): + yield from _python_solids(src_path) + return + from ada.cadit.step.read.native_reader import native_stream_read_step gen = native_stream_read_step(src_path) diff --git a/src/ada/cadit/step/write/ap242_stream.py b/src/ada/cadit/step/write/ap242_stream.py index 4f7c0fa7d..d2835b70e 100644 --- a/src/ada/cadit/step/write/ap242_stream.py +++ b/src/ada/cadit/step/write/ap242_stream.py @@ -739,7 +739,8 @@ def _tessellate_solid(self, g): if fn is None: return None, None defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) - ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) try: mesh = fn([("0", g)], pipeline="libtess2", deflection=defl, angular_deg=ang) except Exception as exc: # noqa: BLE001 - tessellation can't cover this solid @@ -859,6 +860,38 @@ def add_solid_instances(self, g, *, name="shape", color=None, instances=()): self._instances.append((pd, sr, nm, parent_rep, tuple(tf) if tf is not None else None)) return len(instances) + def add_baked_instances(self, g, *, name="shape", color=None, transforms=()) -> int: + """Emit one component per world 4x4 in ``transforms``, each BAKED into a faceted + MANIFOLD_SOLID_BREP whose planar faces are recomputed from the transformed points — so + ANY affine (including the non-uniform-scale mapped-item transforms an IfcMappedItem carries) + is represented losslessly. This mirrors adacpp's step_emit "bake per-instance geometry" + (a scale/shear can't ride a rotation-only STEP placement, and NAUO/AXIS2_PLACEMENT is + rigid-only), so it is the transform-preserving path for mapped/instanced shapes that the + analytic ``add_extrusion``/``add_brep`` writers can't place. Returns the number emitted. + + ``transforms`` is a list of row-major flat-16 4x4 world matrices (``None`` = identity).""" + nm = (name or "shape").replace("'", "''") + saved_tf, saved_t = getattr(self, "_tf", None), getattr(self, "_t", (0.0, 0.0, 0.0)) + emitted = 0 + for m in transforms: + self._tf = tuple(float(x) for x in m) if m is not None else None + self._t = (0.0, 0.0, 0.0) + try: + item = self._emit_faceted_brep(g, nm, color) # bakes self._tf into world points + except Exception as exc: # noqa: BLE001 - one bad instance shouldn't sink the file + logger.warning("ap242 add_baked_instances skipped %r: %s", name, exc) + item = None + finally: + self._tf, self._t = saved_tf, saved_t + if item is None: + continue + if self.assembly: + self._emit_component(item, nm) + else: + self._solids.append(item) + emitted += 1 + return emitted + def _register_asm_path(self, parent_path) -> int | None: """Register the intermediate assembly nodes of ``parent_path`` (root-first ``(rep_id, name)`` levels) and their parent edges; return the rep_id of the @@ -1041,12 +1074,25 @@ def emit(v0, p0, v1, p1): else: return None # unsupported edge geometry - # Key by the EdgeCurve OBJECT identity: a truly-shared edge resolves to the - # same ec object in both adjacent faces (reader memoisation), while the two - # semicircle arcs of one circle are distinct objects — so they are NOT merged - # (a geometric vertex-pair key collides them and corrupts the topology). + # Key by the EDGE_CURVE's VALUE, not the ec object's identity: the Python + # reader memoises shared edges into one object, but NGEOM-hydrated trees + # (native reader / lazy ShapeStore) re-decode one EdgeCurve object per + # referencing face — identity keying would emit duplicate EDGE_CURVEs and + # the shell would read back with free edges (no solid). The value key is the + # raw emit direction (unnormalised — two complementary arcs of one circle + # run opposite ways), the underlying curve's signature (arcs of one circle + # share endpoints; the signature alone doesn't split them, direction and + # t-range do), the trim range, and same_sense. + ts, te = getattr(oe, "t_start", None), getattr(oe, "t_end", None) + key = ( + self._vfor(ec.start), + self._vfor(ec.end), + _edge_curve_value_sig(g), + bool(ec.same_sense), + None if ts is None else (min(ts, te), max(ts, te)), + ) return self._shared_oriented( - id(ec), + key, self._vfor(oe.start), self._vfor(oe.end), # loop traversal direction self._vfor(ec.start), @@ -1270,6 +1316,82 @@ def _curve_to_segs(curve, *, is_outer): return None +def _transform_extrusion(ext, W): + """Apply a world affine 4x4 ``W`` to an :class:`Extrusion`, returning a NEW analytic Extrusion + (exact planar faces + line/arc edges — no tessellation), or None when the transform can't be + carried analytically (an oblique extrude vector under shear, or a circular arc that a + non-uniform in-plane scale would turn elliptic). The caller then facet-bakes that instance. + + The profile's own 2D coordinates absorb the linear map: each 2D point is lifted to 3D in the + source frame, mapped by ``W``'s rotation/scale, and re-projected into a fresh orthonormal frame + fitted to the transformed profile plane — so a rotation, a uniform, or an axis-aligned + non-uniform scale (the common mapped-instance case) all stay a valid ExtrudedAreaSolid.""" + import numpy as np + + Wm = np.asarray(W, dtype=float) + R = Wm[:3, :3] + o = np.asarray(ext.origin, dtype=float) + xd = np.asarray(ext.xdir, dtype=float) + nd = np.asarray(ext.normal, dtype=float) + yd = np.cross(nd, xd) + + # The transformed profile plane's normal is cross(R@xd, R@yd) — NOT R@nd (that only coincides + # for axis-aligned scale/rotation; a shear tilts the plane while leaving R@nd unchanged). + tx, ty = R @ xd, R @ yd + n2 = np.cross(tx, ty) + if np.linalg.norm(n2) < 1e-12: + return None + n_hat = n2 / np.linalg.norm(n2) + x_hat = tx - np.dot(tx, n_hat) * n_hat + if np.linalg.norm(x_hat) < 1e-9: + return None + x_hat /= np.linalg.norm(x_hat) + y_hat = np.cross(n_hat, x_hat) + + # add_extrusion extrudes along `normal` by `depth`; the mapped extrude vector must stay ~parallel + # to the transformed plane normal (no oblique-prism support here) → else facet-fallback. + ev = R @ (float(ext.depth) * nd) + depth2 = float(np.dot(ev, n_hat)) + if np.linalg.norm(ev - depth2 * n_hat) > 1e-6 * (abs(depth2) + 1.0): + return None + if depth2 < 0: # extrude flipped → flip the frame so depth stays positive (add_extrusion req.) + n_hat, y_hat, depth2 = -n_hat, -y_hat, -depth2 + if depth2 <= 1e-12: + return None + + # A non-uniform in-plane scale turns circles into ellipses — a circular-arc Seg can't represent + # that, so bail (facet) when the profile has arcs and the in-plane scale is anisotropic. + sx, sy = np.linalg.norm(R @ xd), np.linalg.norm(R @ yd) + anisotropic = abs(sx - sy) > 1e-6 * max(sx, sy, 1.0) + if anisotropic and any(s.kind == "arc" for loop in [ext.outer, *ext.inners] for s in loop): + return None + + def to2d(p2): + q = R @ (float(p2[0]) * xd + float(p2[1]) * yd) # W@(o+..) - W@o = R@(..) + return (float(np.dot(q, x_hat)), float(np.dot(q, y_hat))) + + def tf_loop(segs): + out = [] + for s in segs: + if s.kind == "arc": + out.append(Seg("arc", to2d(s.start), to2d(s.end), mid=to2d(s.mid))) + else: + out.append(Seg(s.kind, to2d(s.start), to2d(s.end))) + return out + + new_origin = tuple((Wm @ np.array([o[0], o[1], o[2], 1.0]))[:3]) + return Extrusion( + origin=new_origin, + xdir=tuple(x_hat), + normal=tuple(n_hat), + depth=depth2, + outer=tf_loop(ext.outer), + inners=[tf_loop(loop) for loop in ext.inners], + name=ext.name, + color=ext.color, + ) + + def extrusion_from_geometry(geom, *, name="obj", color=None, translate=(0.0, 0.0, 0.0)): """Build an :class:`Extrusion` from an adapy ``Geometry`` whose solid is a plain ``ExtrudedAreaSolid``. Returns None if the geometry is not a supported @@ -1300,6 +1422,16 @@ def extrusion_from_geometry(geom, *, name="obj", color=None, translate=(0.0, 0.0 normal = tuple(float(v) for v in pos.axis) profile = solid.swept_area + if not hasattr(profile, "outer_curve"): + # Parametric profile (Rectangle/I/T from an IFC import) — convert to a + # concrete outline first, same seam the tessellation backends use. + from ada.api.beams.geom_beams import parametric_profile_to_arbitrary + + try: + profile = parametric_profile_to_arbitrary(profile) + except NotImplementedError as e: + logger.warning("%s: swept profile %s not convertible (%s); skipping", name, type(profile).__name__, e) + return None outer = _curve_to_segs(profile.outer_curve, is_outer=True) if outer is None: return None @@ -1494,15 +1626,50 @@ def write_step_stream( geom, name, color, translate = _object_geom_meta(obj) done = False if geom is not None: - ext = extrusion_from_geometry( - geom, name=name, color=color, translate=translate - ) or _primitive_to_extrusion(geom, name=name, color=color, translate=translate) - if ext is not None: - writer.add_extrusion(ext) - done = True - elif writer.add_brep(geom.geometry, name=name, color=color, translate=translate) is not None: - # B-rep fallback: imported shapes, pure shells, analytic faces - done = True + # transforms ride obj.geom (the mapped-item mesh-level instances); solid_geom() + # strips them. World order (matching the tessellation path + the ifcopenshell oracle) + # is placement @ transform[k] @ local — so bake the LOCAL geometry (obj.geom.geometry, + # pre-placement) under self._tf = P @ transform[k]. + obj_geom = getattr(obj, "geom", None) + transforms = getattr(obj_geom, "transforms", None) if obj_geom is not None else None + if transforms: + # Mapped / instanced shape: N world 4x4s from a multi-instance IfcMappedItem. The + # analytic writers place a single solid (dropping N-1 instances). Emit ONE solid + # per instance, ANALYTICALLY where possible — transform the Extrusion (exact + # planar faces + line/arc edges preserved, no tessellation) and emit via + # add_extrusion. Only a transform the analytic form can't carry (oblique/elliptic + # under shear) falls back to a faceted bake — facet-but-present, never dropped. + import numpy as np + + from ada.geom import Geometry + + P = np.asarray(obj.placement.get_matrix4x4(), dtype=float) + base_ext = extrusion_from_geometry( + Geometry(name, obj_geom.geometry), name=name, color=color + ) + n_ok = 0 + for m in transforms: + W = P @ np.asarray(m, dtype=float) + placed = _transform_extrusion(base_ext, W) if base_ext is not None else None + if placed is not None: + writer.add_extrusion(placed) + n_ok += 1 + elif writer.add_baked_instances( + obj_geom.geometry, name=name, color=color, transforms=[tuple(W.ravel())] + ) > 0: + n_ok += 1 + if n_ok > 0: + done = True + else: + ext = extrusion_from_geometry( + geom, name=name, color=color, translate=translate + ) or _primitive_to_extrusion(geom, name=name, color=color, translate=translate) + if ext is not None: + writer.add_extrusion(ext) + done = True + elif writer.add_brep(geom.geometry, name=name, color=color, translate=translate) is not None: + # B-rep fallback: imported shapes, pure shells, analytic faces + done = True emitted += 1 if done else 0 skipped += 0 if done else 1 if progress_callback is not None: @@ -1518,6 +1685,25 @@ def _axis_or(d, default): return list(d) if d is not None else list(default) +def _edge_curve_value_sig(g): + """A value signature of an edge's underlying curve (see _brep_oriented's key). + Mirrors ada.cadit.ngeom.deserialize._edge_curve_sig, duplicated here so the + streaming writer stays numpy-free for the slim worker.""" + if g is None: + return None + tname = type(g).__name__ + pos = getattr(g, "position", None) + loc = tuple(float(v) for v in pos.location) if pos is not None else None + if hasattr(g, "radius"): + return (tname, loc, float(g.radius)) + if hasattr(g, "semi_axis1"): + return (tname, loc, float(g.semi_axis1), float(g.semi_axis2)) + cps = getattr(g, "control_points_list", None) + if cps is not None: + return (tname, tuple(tuple(float(v) for v in p) for p in cps)) + return (tname, loc) + + def _object_geom_meta(obj): """(geom, name, color, translate) for a physical object, or (None, ...) if it has no usable solid geometry. Shared by the extrusion and B-rep emit paths.""" diff --git a/src/ada/cadit/step/write/stream_step_to_ifc.py b/src/ada/cadit/step/write/stream_step_to_ifc.py index fd64673ed..bc2095148 100644 --- a/src/ada/cadit/step/write/stream_step_to_ifc.py +++ b/src/ada/cadit/step/write/stream_step_to_ifc.py @@ -157,7 +157,21 @@ def _curve(self, lines, g): return self._bspline_curve(lines, g) return None - def _edge_curve(self, lines, ec): + def _pcurve_bspline_2d(self, lines, pc): + """Emit a :class:`Pcurve2dBSpline` as a 2D IfcBSplineCurveWithKnots (UV space — + control points are raw parameter pairs, never transformed).""" + cps = self._refs( + [self._emit(lines, f"IfcCartesianPoint(({_r(p[0])},{_r(p[1])}))") for p in pc.control_points_2d] + ) + common = ( + f"{int(pc.degree)},{cps},.UNSPECIFIED.,{_b(bool(pc.closed))},.F.," + f"{self._ilist(pc.knot_multiplicities)},{self._rlist(pc.knots)},.UNSPECIFIED." + ) + if pc.weights: + return self._emit(lines, f"IfcRationalBSplineCurveWithKnots({common},{self._rlist(pc.weights)})") + return self._emit(lines, f"IfcBSplineCurveWithKnots({common})") + + def _edge_curve(self, lines, ec, pcurve=None, surf_id=None): g = ec.edge_geometry import ada.geom.curves as cu @@ -167,23 +181,33 @@ def _edge_curve(self, lines, ec): p0, p1 = ec.start, ec.end d = _unit(_sub(p1, p0)) crv = self._emit(lines, f"IfcLine(#{self._pt(lines, p0)},#{self._vec(lines, d)})") + if pcurve is not None and surf_id is not None: + # Carry the coedge's UV p-curve: without it a trimmed analytic face + # (joint-cut cylinder, exppc B-spline) can't rebuild its wire on + # re-import ('wire build failed' in adacpp). The reader recovers it + # from IfcSurfaceCurve.AssociatedGeometry[0].ReferenceCurve. + pcv = self._emit(lines, f"IfcPcurve(#{surf_id},#{self._pcurve_bspline_2d(lines, pcurve)})") + crv = self._emit(lines, f"IfcSurfaceCurve(#{crv},(#{pcv}),.CURVE3D.)") v0, v1 = self._vertex(lines, ec.start), self._vertex(lines, ec.end) return self._emit(lines, f"IfcEdgeCurve(#{v0},#{v1},#{crv},{_b(ec.same_sense)})") - def _oriented_edge(self, lines, oe): + def _oriented_edge(self, lines, oe, surf_id=None): ec = getattr(oe, "edge_element", oe) - key = id(ec) + pcurve = getattr(oe, "pcurve", None) + # The p-curve lives on the coedge (face-specific UV), so a pcurve-carrying + # edge is cached per (edge, surface) — the bare 3D form stays shared. + key = (id(ec), surf_id) if pcurve is not None else id(ec) edge_id = self._ecache.get(key) if edge_id is None: - edge_id = self._edge_curve(lines, ec) + edge_id = self._edge_curve(lines, ec, pcurve=pcurve, surf_id=surf_id) self._ecache[key] = edge_id return self._emit(lines, f"IfcOrientedEdge($,$,#{edge_id},{_b(oe.orientation)})") - def _loop(self, lines, loop): + def _loop(self, lines, loop, surf_id=None): import ada.geom.curves as cu if isinstance(loop, cu.EdgeLoop): - oe = [self._oriented_edge(lines, e) for e in loop.edge_list] + oe = [self._oriented_edge(lines, e, surf_id=surf_id) for e in loop.edge_list] if not oe: return None return self._emit(lines, f"IfcEdgeLoop({self._refs(oe)})") @@ -248,7 +272,7 @@ def _face(self, lines, face): return None bounds = [] for i, fb in enumerate(face.bounds): - loop = self._loop(lines, fb.bound) + loop = self._loop(lines, fb.bound, surf_id=surf) if loop is None: return None kw = "IfcFaceOuterBound" if i == 0 else "IfcFaceBound" diff --git a/src/ada/cadit/step/write/stream_step_to_mesh.py b/src/ada/cadit/step/write/stream_step_to_mesh.py index be3b51fc2..910fbf259 100644 --- a/src/ada/cadit/step/write/stream_step_to_mesh.py +++ b/src/ada/cadit/step/write/stream_step_to_mesh.py @@ -12,6 +12,7 @@ import struct from typing import Callable +from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG from ada.config import logger ProgressFn = Callable[[str, float], None] @@ -23,7 +24,7 @@ def stream_step_to_mesh( fmt: str, *, deflection: float = 2.0, - angular_deg: float = 20.0, + angular_deg: float = DEFAULT_STREAM_TESS_ANGULAR_DEG, on_progress: ProgressFn | None = None, ) -> dict: """Stream a STEP file to ``fmt`` ('stl' | 'obj'), one solid at a time. Returns diff --git a/src/ada/cadit/visual_parity.py b/src/ada/cadit/visual_parity.py index 8fd5db20c..1f2f7b86a 100644 --- a/src/ada/cadit/visual_parity.py +++ b/src/ada/cadit/visual_parity.py @@ -28,11 +28,26 @@ from ada import Assembly # Structure-preserving formats: (writer(assembly, path), reader(path) -> Assembly, suffix). -# STEP is written via the OCC writer (full geometry, not just extrusions) and -# read via the streaming reader with an OCC fallback (reader="auto"). +# STEP is written via the non-OCC stream writer (the kernel-free AP242 path prod uses), which +# preserves mapped/instanced shapes the OCC writer drops; it falls back to OCC per-file only for +# solids the analytic stream writer can't yet author (swept/revolved/tapered). Read via the +# streaming reader with an OCC fallback (reader="auto"). _FORMAT_IO: dict[str, tuple[Callable, Callable, str]] = {} +def _write_step_parity(assembly, path) -> None: + """Write STEP for the parity round-trip via the non-OCC stream writer (matches the prod + ifc/step→step converter path, and preserves multi-instance mapped shapes the OCC writer + collapses). If the stream writer skipped any solid it can't author analytically + (swept/revolved/tapered), re-write the whole file with the OCC writer, which covers those — + so the leg stays lossless either way. (A model mixing mapped instances AND swept solids would + lose the mapped instances to the OCC rewrite; none exist in the corpus and the stream writer's + coverage is being extended to remove even that.)""" + stats = assembly.to_stp(path, writer="stream") + if isinstance(stats, dict) and stats.get("skipped", 0) > 0: + assembly.to_stp(path, writer="occ") + + def _register_default_formats() -> None: if _FORMAT_IO: return @@ -40,7 +55,7 @@ def _register_default_formats() -> None: _FORMAT_IO["ifc"] = (lambda a, p: a.to_ifc(p), lambda p: ada.from_ifc(p), ".ifc") _FORMAT_IO["xml"] = (lambda a, p: a.to_genie_xml(p), lambda p: ada.from_genie_xml(p), ".xml") - _FORMAT_IO["step"] = (lambda a, p: a.to_stp(p), lambda p: ada.from_step(p, reader="auto"), ".step") + _FORMAT_IO["step"] = (_write_step_parity, lambda p: ada.from_step(p, reader="auto"), ".step") def visualized_element_count(scene: "trimesh.Scene") -> int: @@ -48,7 +63,9 @@ def visualized_element_count(scene: "trimesh.Scene") -> int: Counts mesh / polyline entries one-per-object (build the scene with ``merge_meshes=False``); excludes the placeholder point cloud the converter - seeds for otherwise-empty scenes. + seeds for otherwise-empty scenes, and zero-vertex entries (the OCC STEP + fallback reader materializes one empty Shape from a geometry-less file — + it renders nothing, so it must not count as an element). """ import trimesh @@ -56,13 +73,39 @@ def visualized_element_count(scene: "trimesh.Scene") -> int: for geom in scene.geometry.values(): if isinstance(geom, trimesh.PointCloud): continue # empty-scene placeholder + if len(getattr(geom, "vertices", ())) == 0: + continue # degenerate/empty body — renders nothing n += 1 return n def assembly_element_count(assembly: "Assembly") -> int: - """Visualized-element count for an adapy Assembly (unmerged scene).""" - return visualized_element_count(assembly.to_trimesh_scene(merge_meshes=False)) + """Visualized-element count for an adapy Assembly. + + Counted from the raw tessellation stream — one MeshStore per renderable + object, the exact set ``meshes_to_trimesh(merge_meshes=False)`` turns into + scene entries — WITHOUT assembling a trimesh scene. Scene assembly + (trimesh objects, materials, normals) was ~1/3 of a parity cell's wall + time and contributes nothing to the count. Same exclusions as + :func:`visualized_element_count`: point clouds (the empty-scene + placeholder) and zero-vertex stores (degenerate bodies render nothing).""" + from itertools import chain + + from ada.occ.tessellating import BatchTessellator + from ada.visit.gltf.meshes import MeshType + + bt = BatchTessellator() + # Mirror tessellate_part's object set: physical objects (pipes as + # segments) + welds, which live in their own per-Part container. + objects = chain(assembly.get_all_physical_objects(pipe_to_segments=True), assembly.get_all_welds()) + n = 0 + for ms in bt.batch_tessellate(objects): + if ms is None or ms.type == MeshType.POINTS: + continue + if len(ms.position) == 0: + continue + n += 1 + return n @dataclass @@ -127,6 +170,23 @@ def load_assembly_auto(path: str | Path) -> "Assembly": raise ValueError(f"visual_parity: no loader for source suffix {ext!r}") +def _count_curve_set_roots(path: str | Path, size_limit: int = 64_000_000) -> int: + """Number of loose curve/geometric-set roots (one per placed curve body — e.g. an evaluated + alignment reference curve) in a STEP file. The solid-only native reader skips these; each is a + distinct GEOMETRIC_CURVE_SET / GEOMETRIC_SET entity definition. Size-bounded (such wireframe + bodies are tiny; a multi-GB solid assembly carries none worth a full scan).""" + try: + p = Path(path) + if p.stat().st_size > size_limit: + return 0 + data = p.read_bytes() + except OSError: + return 0 + # "GEOMETRIC_CURVE_SET(" does not contain "GEOMETRIC_SET(" as a substring, so counting both + # names never double-counts. + return data.count(b"GEOMETRIC_CURVE_SET(") + data.count(b"GEOMETRIC_SET(") + + def _count_step_instances(path: str | Path) -> int: """Number of placed solid instances in a STEP file. Counted by streaming the native parser and reading each solid's transform list from the metadata — @@ -134,6 +194,9 @@ def _count_step_instances(path: str | Path) -> int: the count). Falls back to the pure-Python streaming reader.""" from ada.cadit.step.read.native_reader import native_adacpp_step_available + # Native StepNgeomStream counts the solids; add the loose curve/geometric-set roots it can't + # see (one per placed curve body). This keeps native grouping/transform semantics instead of a + # whole-model reload, which would ungroup an OCC-exploded multi-face solid and over-count. if native_adacpp_step_available(): import adacpp @@ -141,7 +204,7 @@ def _count_step_instances(path: str | Path) -> int: for _nbytes, meta in adacpp.cad.StepNgeomStream(str(path)): tf = meta.transforms total += len(tf) if tf else 1 - return total + return total + _count_curve_set_roots(path) import ada @@ -173,7 +236,12 @@ def _count_step_product_instances(path: str | Path) -> int | None: key = tuple(ip[0][-1]) if (ip and ip[0]) else ("root", meta.id) n = len(meta.transforms) or 1 groups[key] = max(groups.get(key, 0), n) - return sum(groups.values()) + # The native StepNgeomStream is solid-only but GROUPS a multi-face solid (OCC's to_stp splits a + # sectioned/swept or sewn multi-shell solid into many SHELL_BASED_SURFACE_MODEL roots) back into + # its one owning product. A whole-model reload would UNGROUP that explosion and massively + # over-count, so instead of deferring we keep the grouped solid count and ADD the loose + # curve/geometric-set roots (one per placed curve body) the native reader can't see. + return sum(groups.values()) + _count_curve_set_roots(path) def _count_ifc_proxies(path: str | Path) -> int: @@ -528,6 +596,13 @@ def cross_format_parity( # source and the IFC leg. Grouping by owning product restores the # one-element-per-object convention both other legs use. n = _count_step_product_instances(out) if fmt == "step" else None + if fmt == "step" and n == 0: + # The native stream counter sees only solid/B-rep roots — a + # wireframe-only output (GEOMETRIC_CURVE_SET wire bodies) + # counts 0 there even though the geometry is present. A + # 0-count file is small by construction, so the exact reload + # count is affordable. + n = None counts[fmt] = n if n is not None else assembly_element_count(reader(out)) except Exception as ex: # noqa: BLE001 - record and continue with the other formats errors[fmt] = f"{type(ex).__name__}: {ex}" diff --git a/src/ada/comms/rest/app.py b/src/ada/comms/rest/app.py index a677a2b36..53b2fc7f1 100644 --- a/src/ada/comms/rest/app.py +++ b/src/ada/comms/rest/app.py @@ -230,6 +230,26 @@ async def lifespan(app: FastAPI): _worker_prune_loop(queue), name="worker-prune", ) + # Worker-registry snapshot refresher. Prime it once now (a single + # ~1s NATS scan at boot) so the very first /config.js is warm, then + # keep it fresh in the background — the config endpoints read the + # cached snapshot instead of hitting NATS on the request path. + app.state.worker_registry_task = None + if queue.enabled: + await _refresh_worker_registry() + app.state.worker_registry_task = asyncio.create_task( + _worker_registry_refresh_loop(), + name="worker-registry-refresh", + ) + # Completed-job KV cleanup. Keeps the shared bucket lean (the KV is a + # transient progress cache; durable history lives in Postgres/S3) so + # keys() scans stay cheap and never storm NATS again. + app.state.job_cleanup_task = None + if queue.enabled: + app.state.job_cleanup_task = asyncio.create_task( + _job_cleanup_loop(queue), + name="job-kv-cleanup", + ) yield # Cancel scheduler + issue bot first so a tick in flight # doesn't try to use a pool / queue that's about to be torn @@ -239,6 +259,8 @@ async def lifespan(app: FastAPI): "issue_bot_task", "profile_parser_task", "worker_prune_task", + "worker_registry_task", + "job_cleanup_task", ): task = getattr(app.state, attr, None) if task is not None and not task.done(): @@ -278,6 +300,41 @@ async def healthz() -> Response: # Public — load balancers + readiness probes hit this. return Response(status_code=200) + # Cached worker-registry snapshot, refreshed off the request path by + # ``_worker_registry_refresh_loop``. ``queue.list_workers()`` is an + # N+1 over NATS KV (list keys, then one round-trip per worker key); + # the SPA fetches ``/config.js`` (and later ``/api/config``) on its + # critical startup path, and each of those endpoints derived exts / + # conversions / utilities by calling ``list_workers()`` 2-3 times. + # That put 2.5-4s of blocking NATS latency on every page load. The + # helpers below now read this snapshot synchronously so page load + # never waits on NATS; the background task keeps it fresh (~3s). The + # snapshot changes on worker-heartbeat timescales, so a few seconds + # of staleness is immaterial to the picker / capability matrix. + # Empty until the first refresh tick (same observable state as a + # queue-disabled deploy — the SPA falls back to its static set). + _worker_registry: dict = {"workers": [], "image_tag": None, "ts": 0.0} + + async def _refresh_worker_registry() -> None: + """Snapshot the worker registry + worker image tag into + ``_worker_registry``. Defensive: a failed NATS call is logged and + leaves the previous snapshot in place rather than blanking it.""" + if not queue.enabled: + return + try: + workers = await queue.list_workers() + except Exception: + logger.exception("worker registry refresh: list_workers failed") + return + image_tag = _worker_registry.get("image_tag") + try: + image_tag = await queue.get_meta("worker_image_tag") + except Exception: + logger.exception("worker registry refresh: get_meta failed") + _worker_registry["workers"] = workers + _worker_registry["image_tag"] = image_tag + _worker_registry["ts"] = time.time() + async def _is_accepted_source(key: str) -> bool: """``is_supported_source`` plus a check against the workers' advertised extra extensions. Use this on every upload / bake @@ -311,11 +368,7 @@ async def _worker_advertised_exts() -> list[str]: """ if not queue.enabled: return [] - try: - workers = await queue.list_workers() - except Exception: - logger.exception("config: failed to read worker registry") - return [] + workers = _worker_registry["workers"] out: set[str] = set() for w in workers: for raw in w.get("source_exts") or []: @@ -348,11 +401,7 @@ async def _worker_advertised_conversions() -> list[dict]: """ if not queue.enabled: return [] - try: - workers = await queue.list_workers() - except Exception: - logger.exception("config: failed to read worker registry for matrix") - return [] + workers = _worker_registry["workers"] merged: dict[str, set[str]] = {} # from → target → option-name → option-dict. Per-job knob schemas are unioned across workers: # for an enum option (e.g. step_glb_pipeline) the enum VALUES are unioned, so an engine that @@ -414,11 +463,7 @@ async def _worker_advertised_utilities() -> list[dict]: """ if not queue.enabled: return [] - try: - workers = await queue.list_workers() - except Exception: - logger.exception("config: failed to read worker registry for utilities") - return [] + workers = _worker_registry["workers"] by_name: dict[str, dict] = {} for w in workers: for spec in w.get("utilities") or []: @@ -437,12 +482,8 @@ async def api_config() -> JSONResponse: # publishes its tag on startup. Either may be missing in dev / # local runs; the SPA hides the row when both are empty. viewer_tag = os.environ.get("ADA_IMAGE_TAG", "").strip() or None - worker_tag: str | None = None - if queue.enabled: - try: - worker_tag = await queue.get_meta("worker_image_tag") - except Exception: - logger.exception("config: failed to read worker image tag") + # Cached snapshot — no NATS on the request path (see _worker_registry). + worker_tag: str | None = _worker_registry["image_tag"] if queue.enabled else None extra_source_exts = await _worker_advertised_exts() # Subset of stream-readable extensions that the legacy /convert # pipeline does NOT handle. The SPA uses this to pick between @@ -641,17 +682,15 @@ async def api_rpc( payload = await request.body() if not payload: raise HTTPException(status_code=400, detail="empty body") - # Pull the worker-advertised extension set so the file lister - # can flag plug-in formats (e.g. .odb when an abaqus-capability - # worker is online) instead of silently dropping them. Cheap - # NATS KV read; failures degrade to the static list. - try: - extra_source_exts = frozenset(await _worker_advertised_exts()) - except Exception: - logger.exception("rpc: failed to read worker-advertised exts") - extra_source_exts = frozenset() + # Hand dispatch a lazy provider for the worker-advertised extension + # set (used only by the file lister, to flag plug-in formats like + # .odb when an abaqus-capability worker is online). Passing it lazily + # keeps the worker-registry read off every non-LIST_FILE command — + # server-info, view-file, etc. no longer pay it — so worker-listing + # never gates file-finding. The read itself is a cached in-memory + # lookup (see _worker_registry) that degrades to the static list. try: - reply = await dispatch(payload, storage, scope, extra_source_exts=extra_source_exts) + reply = await dispatch(payload, storage, scope, exts_provider=_worker_advertised_exts) except Exception as exc: logger.exception("rpc dispatch failed") raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -684,6 +723,17 @@ async def api_me( for p in projects: scopes.append({"kind": "project", "id": p["id"], "name": p["name"]}) + # Corpus scopes are admin-only (scope_can_access gates them). Advertise + # them here so an admin can browse + visualise corpus files straight from + # the main storage panel — the same list/convert flow every other scope + # uses. Non-admins never see them; the backend rejects the scope anyway. + if user.is_admin and pool is not None: + try: + for c in await db_module.list_corpora(pool): + scopes.append({"kind": "corpus", "id": c["slug"], "name": c["name"]}) + except Exception: + logger.exception("api_me: listing corpora failed") + return JSONResponse( { "sub": user.sub, @@ -1811,11 +1861,20 @@ async def api_scope_convert( if not queue.enabled: raise HTTPException(status_code=503, detail="conversion disabled (no NATS configured)") + # A re-conversion (gallery "Re-convert") always re-runs and writes to the SEPARATE + # ``_reconvert/`` namespace, so it never overwrites the ``_derived/`` audit product in a + # corpus scope. Regular converts keep the cached ``_derived/`` short-circuit below. + reconvert = bool(body.get("reconvert")) try: - derived_key = derived_key_for(source_key, target_format, step=step, field=field) + if reconvert: + from .converter import reconvert_key_for + + derived_key = reconvert_key_for(source_key, target_format) + else: + derived_key = derived_key_for(source_key, target_format, step=step, field=field) except UnsupportedFormat as exc: raise HTTPException(status_code=415, detail=str(exc)) from exc - if await storage.exists(scope_obj, derived_key): + if not reconvert and await storage.exists(scope_obj, derived_key): await _audit( request, user, @@ -1849,6 +1908,8 @@ async def api_scope_convert( step=step, field=field, conversion_options=conversion_options, + derived_key=derived_key if reconvert else None, + force_rebuild=reconvert, ) except Exception as exc: logger.exception("enqueue failed") @@ -2845,6 +2906,49 @@ def _next_fire(cron_expr: str, *, after=None): base = after or datetime.now(timezone.utc) return croniter(cron_expr, base).get_next(datetime) + async def _worker_registry_refresh_loop() -> None: + """Background task: refresh the cached worker-registry snapshot + (:data:`_worker_registry`) every ``REFRESH_INTERVAL_S`` so the + config endpoints never call ``list_workers()`` (an N+1 over NATS + KV) on the request path. Defensive — a failed tick keeps the last + good snapshot; only ``asyncio.CancelledError`` exits (shutdown).""" + # 15s (was 3s): the refresh does a KV keys() scan, and worker + # capabilities/tags only change on a worker's ~15s heartbeat, so a + # tighter interval bought nothing but scan traffic. Cheap now that the + # KV is kept lean (see _job_cleanup_loop), but no reason to over-poll. + REFRESH_INTERVAL_S = 15.0 + logger.info("worker registry refresh: starting (every %ss)", REFRESH_INTERVAL_S) + try: + while True: + await asyncio.sleep(REFRESH_INTERVAL_S) + await _refresh_worker_registry() + except asyncio.CancelledError: + logger.info("worker registry refresh: stopped") + raise + + async def _job_cleanup_loop(q) -> None: + """Background task: periodically drop completed job entries from the KV + so the shared bucket stays small. The KV is a transient in-flight + progress cache only — the durable record lives in Postgres (audit) and + S3 (blobs/profiles). Without this, terminal job entries piled up + (observed: 47k), so every worker-registry keys() scan replayed the whole + bucket and stormed NATS. Grace keeps a completed job readable long + enough for a polling client to see its final state. Defensive per tick; + only CancelledError exits.""" + CLEANUP_INTERVAL_S = 300.0 + GRACE_S = 900.0 # keep terminal jobs ~15min so the frontend's final poll still resolves + logger.info("job KV cleanup: starting (every %ss, grace %ss)", CLEANUP_INTERVAL_S, GRACE_S) + try: + while True: + await asyncio.sleep(CLEANUP_INTERVAL_S) + try: + await q.purge_completed_jobs(grace_s=GRACE_S) + except Exception: + logger.exception("job KV cleanup: tick failed") + except asyncio.CancelledError: + logger.info("job KV cleanup: stopped") + raise + async def _worker_prune_loop(q) -> None: """Background task: hourly, hard-prune worker registry entries unseen for ``WORKER_PRUNE_AFTER_S`` (2 days). Defensive — one failed tick is logged, not fatal; only @@ -3371,20 +3475,26 @@ async def _run_issue_bot_for_conversion(pool, row: dict) -> None: ) async def _dispatch_auto_validation(pool, parent: dict) -> None: - """Append a validation (cross-format parity) pass to a finished + """Dispatch the validation (cross-format parity) pass of an ``auto_validate`` conversion run — *into the same run*, not a new one. - The run's total grows by the parity cell count and it reopens to - ``running`` until those cells land (then it re-finishes). The claim - already stamped the parent so this runs once; failures are logged but - never break the poller tick.""" + Runs dispatched with an upfront reservation (``validate_total > 0``) + already count these cells in their total, so dispatch consumes the + reservation; pre-reservation runs get their total extended (and + reopen from ``finished``) as before. The claim already stamped the + parent so this runs once; failures are logged but never break the + poller tick.""" try: s = _parse_scope(parent["scope"], _SystemUser()) s = await _resolve_project_scope(pool, s) except Exception: logger.exception("auto-validate: scope resolution failed for run %s", parent["id"]) + if (parent.get("validate_total") or 0) > 0: + # Release the reservation — no parity cells will ever be + # enqueued, so the run must not hang 'running' on them. + await db_module.consume_audit_run_validation_reserve(pool, parent["id"], 0) return - # Awaited (not fire-and-forget) so the run is reopened with its parity - # cells before the issue-bot drain in the same tick can claim it. + # Awaited (not fire-and-forget) so the run holds its parity cells + # before the issue-bot drain in the same tick can claim it. try: await _audit_dispatch( parent["id"], @@ -3395,6 +3505,7 @@ async def _dispatch_auto_validation(pool, parent: dict) -> None: False, validate_only=True, extend=True, + consume_reserve=(parent.get("validate_total") or 0) > 0, ) logger.info("auto-validate: appended validation cells to run %s", parent["id"]) except Exception: @@ -3421,11 +3532,13 @@ async def _issue_bot_loop(pool) -> None: try: while True: try: - # Auto-validate first: a finished auto_validate run gets its - # parity cells appended (reopening it to 'running'), so the - # issue-bot below only claims a run once it's *truly* done — - # conversions and validation together. Claimed once (the - # claim stamps auto_validate_dispatched_at). + # Auto-validate first: an auto_validate run whose conversion + # cells have landed gets its parity cells dispatched (the + # run stays 'running' on its upfront-reserved total; legacy + # finished runs reopen), so the issue-bot below only claims + # a run once it's *truly* done — conversions and validation + # together. Claimed once (the claim stamps + # auto_validate_dispatched_at). while True: parent = await db_module.claim_audit_run_for_auto_validate(pool) if parent is None: @@ -3454,23 +3567,23 @@ async def _issue_bot_loop(pool) -> None: logger.info("issue-bot poller: stopped") raise - async def _audit_run_list_cells( - scope_obj: Scope, + def _audit_cells_for_files( + files, validate_only: bool, ) -> list[tuple[str, str]]: - """Enumerate the (source_key, target_format) cells for an audit - run over ``scope_obj`` — the scope's non-derived, supported - source files crossed with the converter matrix. ``validate_only`` - emits only per-source ``parity`` cells. Shared by the NATS - dispatcher, the WASM dispatcher, and the cells endpoint so the - three never disagree on what a run covers. May raise on a scope - listing failure (caller decides how to surface it).""" - from .converter import ConverterRegistry, is_derived_key, is_supported_source + """Pure cell enumeration over an already-fetched file listing — + lets the dispatcher derive both the conversion grid and the + parity-cell reservation from ONE listing, so the two counts + can't disagree on what files existed at dispatch time.""" + from .converter import ConverterRegistry, is_hidden_key, is_supported_source - files = await storage.list(scope_obj) cells: list[tuple[str, str]] = [] for f in files: - if is_derived_key(f.key): + # Skip ALL internal namespaces, not just _derived/: _overlays/ (utility previews) and + # _reconvert/ (gallery throwaway re-conversions) are not corpus sources and must never + # become audit cells. is_hidden_key covers all three; is_derived_key did not match + # _reconvert/ (deliberately not a derived product), so it leaked into runs. + if is_hidden_key(f.key): continue if not is_supported_source(f.key): continue @@ -3488,6 +3601,20 @@ async def _audit_run_list_cells( cells.append((f.key, target_format)) return cells + async def _audit_run_list_cells( + scope_obj: Scope, + validate_only: bool, + ) -> list[tuple[str, str]]: + """Enumerate the (source_key, target_format) cells for an audit + run over ``scope_obj`` — the scope's non-derived, supported + source files crossed with the converter matrix. ``validate_only`` + emits only per-source ``parity`` cells. Shared by the NATS + dispatcher, the WASM dispatcher, and the cells endpoint so the + three never disagree on what a run covers. May raise on a scope + listing failure (caller decides how to surface it).""" + files = await storage.list(scope_obj) + return _audit_cells_for_files(files, validate_only) + async def _audit_dispatch( run_id: str, scope_obj: Scope, @@ -3497,6 +3624,8 @@ async def _audit_dispatch( force_rebuild: bool = False, validate_only: bool = False, extend: bool = False, + reserve_validation: bool = False, + consume_reserve: bool = False, ) -> None: """Enumerate the scope's files × the converter matrix and enqueue one regular convert job per cell. Cached cells @@ -3509,12 +3638,21 @@ async def _audit_dispatch( measurement runs where a 4-hour audit re-run mustn't short-circuit 80% of cells against prior outputs. - ``extend`` *appends* the enumerated cells to an already-finished run - (growing its total + reopening it) instead of setting the total from - scratch — the auto-validation pass uses this to fold its parity cells - into the conversion run rather than spawning a separate run. With - ``extend`` an empty cell set is a no-op (the finished run is left as - is) rather than a zero-total finish. + ``reserve_validation`` (auto-validate runs) counts the parity cells + into the run's total upfront — as ``validate_total`` — so the total + is complete from the very start instead of growing when the + validation pass begins. The parity cells themselves are enqueued + later by the auto-validate poller, which fires once the conversion + cells alone have landed. + + ``extend`` *appends* the enumerated cells to an existing run instead + of setting the total from scratch — used by the validation pass. + With ``consume_reserve`` (a run dispatched with + ``reserve_validation``) the reserved count is swapped for the actual + parity cell count, so the total only moves by scope drift between + the two enumerations. Without it (manual Validate on a finished run, + or pre-reservation rows) the total grows by the parity cell count + and the run reopens; an empty cell set is then a no-op. Errors during enumeration / enqueue surface as a ``failed`` audit row on the cell that tripped them — the run still @@ -3528,19 +3666,37 @@ async def _audit_dispatch( # if it gets zero, so a typo'd scope shows up immediately in # the UI rather than as a perpetually-running ghost. try: - cells = await _audit_run_list_cells(scope_obj, validate_only) + files = await storage.list(scope_obj) except Exception: logger.exception("audit run %s: scope listing failed", run_id) - if not extend: - await db_module.set_audit_run_total(pool, run_id, 0) + if extend: + if consume_reserve: + # Release the reservation so the run can finish instead of + # hanging forever on cells that will never be enqueued. + await db_module.consume_audit_run_validation_reserve(pool, run_id, 0) + return + await db_module.set_audit_run_total(pool, run_id, 0) return + cells = _audit_cells_for_files(files, validate_only) if extend: - if not cells: - return # nothing to append; leave the finished run untouched - await db_module.extend_audit_run_total(pool, run_id, len(cells)) + if consume_reserve: + # Swap the upfront reservation for the actual parity cell + # count (finishes the run right here when that count is 0). + await db_module.consume_audit_run_validation_reserve(pool, run_id, len(cells)) + if not cells: + return + else: + if not cells: + return # nothing to append; leave the finished run untouched + await db_module.extend_audit_run_total(pool, run_id, len(cells)) else: - await db_module.set_audit_run_total(pool, run_id, len(cells)) + # Reserve the auto-validation parity cells in the total now — the + # counter-bump finish check compares against the full total, so + # the run stays 'running' through the gap between the last + # conversion cell and the poller dispatching the parity cells. + reserved = len(_audit_cells_for_files(files, True)) if reserve_validation else 0 + await db_module.set_audit_run_total(pool, run_id, len(cells) + reserved, validate_total=reserved) if not cells: return @@ -3794,6 +3950,9 @@ async def admin_audit_run_create( pool, force_rebuild, validate_only, + # Count the auto-validation parity cells into the run total + # from the start (dispatched later by the poller). + reserve_validation=auto_validate, ) return JSONResponse(run, status_code=202) @@ -3927,9 +4086,76 @@ async def admin_audit_run_re_dispatch( pool, prior["force_rebuild"], False, + reserve_validation=prior["auto_validate"], ) return JSONResponse(run, status_code=202) + @admin.post("/audit/runs/{run_id}/rerun-cell") + async def admin_audit_run_rerun_cell( + run_id: str, + request: Request, + user: User = Depends(auth_module.current_user), + ) -> JSONResponse: + """Re-run one cell of an existing run in place (right-click → Rerun). + + Enqueues a single force-rebuild conversion for ``{key, target}`` against + the run's own scope/pool, re-points the cell's audit row at the new job + and reopens the run (``db.reset_audit_cell_for_rerun``). The worker's + normal completion path updates the row and re-finishes the run, so the + counters, the sum-of-cells runtime and the grid cell all reflect the + fresh result — no full re-dispatch of the other 900+ cells.""" + from .converter import derived_key_for + + body = await request.json() + key = (body or {}).get("key") + target = (body or {}).get("target") + if not key or not target: + raise HTTPException(status_code=400, detail="key and target are required") + if target == "parity": + raise HTTPException( + status_code=400, + detail="parity cells have no derived product — use Re-validate on the run instead", + ) + + pool = _require_pool(request) + prior = await db_module.get_audit_run(pool, run_id) + if prior is None: + raise HTTPException(status_code=404, detail="audit run not found") + worker_pool = prior["worker_pool"] + if isinstance(worker_pool, str) and worker_pool.strip().lower() == _WASM_POOL: + raise HTTPException(status_code=400, detail="cannot re-run a single cell of a wasm run from the server") + if not queue.enabled: + raise HTTPException(status_code=503, detail="conversion disabled (no NATS configured)") + + s = _parse_scope(prior["scope"], user) + s = await _resolve_project_scope(pool, s) + if not await scope_can_access(user, s, pool): + raise HTTPException(status_code=403, detail="forbidden") + + try: + derived_key_for(key, target) # validate the target is convertible for this source + except Exception as exc: + raise HTTPException(status_code=400, detail=f"not a convertible cell: {exc}") from exc + + try: + job = await queue.enqueue( + key, + target, + scope_kind=s.kind, + scope_id=s.id, + target_capability=worker_pool, + force_rebuild=True, + ) + except Exception as exc: + logger.exception("rerun-cell enqueue failed for %s -> %s", key, target) + raise HTTPException(status_code=503, detail=f"enqueue failed: {exc}") from exc + + found = await db_module.reset_audit_cell_for_rerun(pool, run_id, key, target, job.job_id) + if not found: + raise HTTPException(status_code=404, detail="cell not found in this run") + run = await db_module.get_audit_run(pool, run_id) + return JSONResponse(run, status_code=202) + @admin.post("/audit/runs/{run_id}/validate") async def admin_audit_run_validate( run_id: str, @@ -4123,6 +4349,27 @@ async def admin_corpora_create( raise return JSONResponse(row, status_code=201) + @admin.patch("/corpora/{slug}") + async def admin_corpora_update(slug: str, request: Request) -> JSONResponse: + """Update a corpus's display name / description. + + Body: ``{"name": "...", "description": "..."}`` — name required + non-empty, empty description clears it. The slug itself is + immutable: it's baked into the storage prefix + (``corpus//``) and scope URLs, so renaming it would + orphan the bucket bytes. + """ + pool = _require_pool(request) + body = await request.json() if await request.body() else {} + name = (body.get("name") or "").strip() + description = (body.get("description") or "").strip() or None + if not name: + raise HTTPException(status_code=400, detail="name required") + row = await db_module.update_corpus(pool, slug, name=name, description=description) + if row is None: + raise HTTPException(status_code=404, detail=f"corpus {slug!r} not found") + return JSONResponse(row) + @admin.delete("/corpora/{slug}") async def admin_corpora_archive(slug: str, request: Request) -> JSONResponse: """Soft-delete a corpus by slug. Storage bytes are NOT wiped — @@ -5046,7 +5293,9 @@ async def admin_clear_metrics(request: Request) -> JSONResponse: entry["profile_key"], exc, ) - blob_errors.append(f"{entry['profile_key']}: {exc}") + # Report only the failed key to the client; the exception detail is logged + # above (avoid leaking backend/stack-trace text in the response). + blob_errors.append(entry["profile_key"]) return JSONResponse( { "rows_cleared": result["rows_cleared"], @@ -5063,6 +5312,7 @@ async def admin_audit( scope_id: str | None = None, action: str | None = None, target: str | None = None, + status: str | None = None, key: str | None = None, before_id: int | None = None, limit: int = 100, @@ -5073,6 +5323,8 @@ async def admin_audit( key_like = (key or "").strip() or None # ``target`` filters by the conversion's target format (glb / ifc / step / …). target_format = (target or "").strip().lstrip(".").lower() or None + # ``status`` filters by job state (queued / running / done / error). + status_norm = (status or "").strip().lower() or None rows = await db_module.list_audit( pool, user_sub=user_sub, @@ -5080,6 +5332,7 @@ async def admin_audit( scope_id=scope_id, action=action, target_format=target_format, + statuses=[status_norm] if status_norm else None, key_like=key_like, limit=limit, before_id=before_id, @@ -5473,9 +5726,11 @@ async def admin_keys_copy_from( # default raises ``copy-if-not-exists not supported``); the # application-layer dst_keys pre-check is the real collision guard. await storage.copy(src_scope, key, scope_obj, key, overwrite=True) - except Exception as exc: + except Exception: + # Full detail is logged; return a generic reason so backend/stack-trace + # text isn't exposed in the response (CodeQL py/stack-trace-exposure). logger.exception("admin: copy failed for %s (%s -> %s)", key, src_raw, scope_obj.prefix()) - failed.append({"key": key, "reason": str(exc)}) + failed.append({"key": key, "reason": "copy failed"}) continue dst_keys.add(key) copied.append({"key": key}) @@ -5495,12 +5750,8 @@ async def config_js() -> PlainTextResponse: import json as _json viewer_tag = os.environ.get("ADA_IMAGE_TAG", "").strip() or None - worker_tag: str | None = None - if queue.enabled: - try: - worker_tag = await queue.get_meta("worker_image_tag") - except Exception: - logger.exception("config.js: failed to read worker image tag") + # Cached snapshot — no NATS on the request path (see _worker_registry). + worker_tag: str | None = _worker_registry["image_tag"] if queue.enabled else None extra_source_exts = await _worker_advertised_exts() streaming_only_exts = sorted(e for e in extra_source_exts if e not in LEGACY_CONVERT_EXTS) conversion_matrix = await _worker_advertised_conversions() diff --git a/src/ada/comms/rest/converter.py b/src/ada/comms/rest/converter.py index b5d27b577..dfc6af238 100644 --- a/src/ada/comms/rest/converter.py +++ b/src/ada/comms/rest/converter.py @@ -430,6 +430,20 @@ def derived_key_for( return f"_derived/{src}.{fmt}" +def reconvert_key_for(source_key: str, target_format: str = "glb") -> str: + """Output key for a user-triggered *re-conversion* (gallery "Re-convert" button). + + Lives in a SEPARATE ``_reconvert/`` namespace from the ``_derived/`` convert cache, so a + re-convert never overwrites the audit-run product in a corpus scope — the derived cache + stays exactly as the audit produced it. One blob per (source, format): re-converting the + same file again overwrites its own ``_reconvert/`` blob (throwaway, not accumulating). + """ + fmt = target_format.lstrip(".").lower() + if fmt not in TARGET_FORMATS: + raise UnsupportedFormat(f"unknown target format: {target_format!r}") + return f"_reconvert/{source_key.strip('/')}.{fmt}" + + # Suffix appended to derived keys for cached result-meta JSON. Lives in # the same _derived/ namespace, so it's hidden from the user file list # but still scoped to the source. @@ -486,7 +500,7 @@ def is_derived_key(key: str) -> bool: # ephemeral overlay as a source's derived product. def is_hidden_key(key: str) -> bool: k = key.lstrip("/") - return k.startswith("_derived/") or k.startswith("_overlays/") + return k.startswith("_derived/") or k.startswith("_overlays/") or k.startswith("_reconvert/") def is_versions_artefact_key(key: str) -> bool: @@ -942,7 +956,10 @@ def _have(mod: str) -> bool: _STEP_GLB_PIPELINE_ADACPP_CGAL, _STEP_GLB_PIPELINE_ADACPP_HYBRID, ) -_GLB_TESS_ENGINE_DEFAULT = _STEP_GLB_PIPELINE_OCC +# Advertised default for the non-STEP →GLB engine option. Kept in lockstep with the RUNTIME default +# (`_default_glb_tess_engine`, which returns libtess2 whenever adacpp is importable) so the SPA/audit +# UI reflects the path actually taken. libtess2 gracefully degrades to OCC on an adacpp-less pool. +_GLB_TESS_ENGINE_DEFAULT = _STEP_GLB_PIPELINE_LIBTESS2 _GLB_ENGINE_TO_STREAM = { _STEP_GLB_PIPELINE_LIBTESS2: "libtess2", _STEP_GLB_PIPELINE_ADACPP_OCC: "occ", @@ -951,6 +968,270 @@ def _have(mod: str) -> bool: } +# --------------------------------------------------------------------------- +# Serializer × Tessellator matrix — SINGLE SOURCE OF TRUTH for the SPA's +# reconvert dropdowns (gallery tools + ConversionRow). Two user-facing axes: +# +# * serializer — WHICH code path drives the →GLB conversion: +# cpp pure-C++ adacpp (STEP: adacpp-native; IFC: native_ifc_to_glb) +# python Python-orchestrated (ada import + BatchTessellator/stream reader) +# wasm client-side, runs entirely in the browser (no server round-trip) +# * tessellator — WHICH kernel meshes the geometry. It is DEPENDENT on the +# serializer (enum_by): the server `python` serializer exposes +# the full kernel choice; `cpp` pins libtess2; the client `wasm` +# serializer exposes its two in-browser engines (native embind +# module / Pyodide wheels). +# +# The frontend renders both dropdowns purely from the option schema advertised +# on the conversion matrix (``labels`` + ``enum_by``) and sends back opaque +# ``{serializer, tessellator}`` tokens — it hardcodes NONE of this vocabulary. +# The backend resolver (:func:`_apply_glb_serializer`) folds those tokens into +# the existing engine knobs (``step_glb_pipeline`` / ``glb_tess_engine``) plus +# the native-vs-python routing. The client serializer carries ``runtime="client"`` +# so the SPA routes it to the in-browser pipeline instead of the worker; it +# never reaches this resolver. +_GLB_SERIALIZER_CPP = "cpp" +_GLB_SERIALIZER_PYTHON = "python" +_GLB_SERIALIZER_WASM = "wasm" + +# Human labels for every serializer token (superset; each row advertises only +# the subset that applies to its source family). +_GLB_SERIALIZER_LABELS = { + _GLB_SERIALIZER_CPP: "C++ (adacpp native)", + _GLB_SERIALIZER_PYTHON: "Python (ada)", + _GLB_SERIALIZER_WASM: "WASM (browser)", +} +# Serializers that execute in the browser — the SPA routes these to its +# in-browser pipeline and gates them on client capability (wasmSupport). +_GLB_CLIENT_SERIALIZERS = frozenset({_GLB_SERIALIZER_WASM}) + +_GLB_TESS_LABELS = { + "native": "Native (libtess2)", + "pyocc": "PythonOCC (OCC)", + "adacpp-occ": "adacpp OCC", + "cgal": "adacpp CGAL", + "ifc-hybrid": "ifcOpenShell hybrid", + "occ": "OCC streaming reader", + # client-side (wasm serializer) engines: + "wasm-native": "Native (WASM module)", + "pyodide": "Pyodide (wasm wheels)", +} + +# Per (serializer, source-family) the tessellator tokens offered. ``step`` = +# STEP/STP sources (own streaming reader + OCC reader); ``generic`` = every +# other →GLB source (ifc / xml / sat / fem / obj / stl) driven by to_gltf's +# BatchTessellator. The lists ARE the enum_by mapping the frontend uses to +# repopulate the tessellator dropdown when the serializer changes. +_GLB_SERIALIZER_TESS = { + (_GLB_SERIALIZER_CPP, "step"): ["native"], + (_GLB_SERIALIZER_CPP, "generic"): ["native"], + (_GLB_SERIALIZER_PYTHON, "step"): ["native", "pyocc", "adacpp-occ", "cgal", "ifc-hybrid", "occ"], + (_GLB_SERIALIZER_PYTHON, "generic"): ["native", "pyocc", "adacpp-occ", "cgal", "ifc-hybrid"], + (_GLB_SERIALIZER_WASM, "step"): ["wasm-native", "pyodide"], + (_GLB_SERIALIZER_WASM, "generic"): ["wasm-native", "pyodide"], +} +# Serializer ordering per family (default is the first entry). cpp is the +# default server path for both; the client serializer comes last. +_GLB_SERIALIZER_ORDER = ( + _GLB_SERIALIZER_CPP, + _GLB_SERIALIZER_PYTHON, + _GLB_SERIALIZER_WASM, +) + +# tessellator token → effective engine knob, reusing the existing pipeline +# identities so there is exactly one definition of each engine. +_TESS_TO_STEP_PIPELINE = { + "native": _STEP_GLB_PIPELINE_LIBTESS2, + "pyocc": _STEP_GLB_PIPELINE_OCC, + "occ": _STEP_GLB_PIPELINE_OCC, + "adacpp-occ": _STEP_GLB_PIPELINE_ADACPP_OCC, + "cgal": _STEP_GLB_PIPELINE_ADACPP_CGAL, + "ifc-hybrid": _STEP_GLB_PIPELINE_ADACPP_HYBRID, +} +_TESS_TO_GLB_ENGINE = { + "native": _STEP_GLB_PIPELINE_LIBTESS2, + "pyocc": _STEP_GLB_PIPELINE_OCC, + "adacpp-occ": _STEP_GLB_PIPELINE_ADACPP_OCC, + "cgal": _STEP_GLB_PIPELINE_ADACPP_CGAL, + "ifc-hybrid": _STEP_GLB_PIPELINE_ADACPP_HYBRID, +} + + +def _glb_source_family(source_ext: str) -> str: + """'step' for STEP/STP sources, else 'generic' — selects the serializer + tessellator vocabulary + engine-knob axis.""" + return "step" if source_ext.lower().lstrip(".") in ("step", "stp") else "generic" + + +def _glb_serializer_options(source_ext: str) -> list[dict]: + """Build the ``serializer`` + dependent ``tessellator`` enum options for a + →GLB row. Single source: vocabulary, labels, defaults and the + serializer→tessellator dependency all come from the module spec above, so + the frontend can render the two dependent dropdowns without hardcoding any + of it. ``enum_by`` maps each serializer value to its allowed tessellator + tokens; ``runtime='client'`` flags the browser-side serializers.""" + fam = _glb_source_family(source_ext) + serializers = [s for s in _GLB_SERIALIZER_ORDER if (s, fam) in _GLB_SERIALIZER_TESS] + enum_by = {s: list(_GLB_SERIALIZER_TESS[(s, fam)]) for s in serializers} + # Union of tessellator tokens across serializers, ordered by first appearance. + tess_tokens: list[str] = [] + for s in serializers: + for tok in enum_by[s]: + if tok not in tess_tokens: + tess_tokens.append(tok) + default_ser = serializers[0] + return [ + { + "name": "serializer", + "type": "enum", + "title": "Serializer", + "default": default_ser, + "enum": serializers, + "labels": {s: _GLB_SERIALIZER_LABELS[s] for s in serializers}, + "runtime": {s: ("client" if s in _GLB_CLIENT_SERIALIZERS else "server") for s in serializers}, + "description": ( + "Conversion code path for →GLB. 'cpp' = pure-C++ adacpp (fastest, lowest memory); " + "'python' = Python-orchestrated (lets you pick the tessellation kernel below); " + "'wasm' / 'pyodide' run entirely in your browser (no server round-trip)." + ), + }, + { + "name": "tessellator", + "type": "enum", + # Mesh target → this axis meshes the geometry ("Tessellator"). (For B-rep targets the + # analogous axis is titled "Writer" — same mechanism, set on those rows.) + "title": "Tessellator", + "default": enum_by[default_ser][0], + "enum": tess_tokens, + "labels": {t: _GLB_TESS_LABELS.get(t, t) for t in tess_tokens}, + # Dependent dropdown: the valid tessellators for each serializer. The + # frontend repopulates + reselects when the serializer changes. + "enum_by": enum_by, + "depends_on": "serializer", + "description": ( + "Tessellation kernel. Only the 'python' serializer exposes a choice; the other " + "serializers pin their own kernel." + ), + }, + ] + + +def _apply_glb_serializer( + source_ext: str, + serializer: str | None, + tessellator: str | None, + *, + step_glb_pipeline: str | None, + glb_tess_engine: str | None, +) -> tuple[str | None, str | None, bool]: + """Fold ``serializer`` / ``tessellator`` tokens into the effective engine + knobs. Returns ``(step_glb_pipeline, glb_tess_engine, force_python)``. + + ``force_python`` tells IFC→GLB to bypass the pure-native path and route + through the ifcopenshell import + BatchTessellator (so the chosen kernel + actually takes effect). When ``serializer`` is unset the explicit knobs + (and their env/defaults) are returned untouched — full back-compat with the + admin panel + ``ADAPY_*`` env selection. Client serializers never reach a + worker, so they are treated as no-ops here.""" + if not serializer or serializer in _GLB_CLIENT_SERIALIZERS: + return step_glb_pipeline, glb_tess_engine, False + fam = _glb_source_family(source_ext) + if serializer == _GLB_SERIALIZER_CPP: + if fam == "step": + step_glb_pipeline = _STEP_GLB_PIPELINE_ADACPP_NATIVE + # generic/IFC 'cpp' = the native_ifc_to_glb default path — nothing to force. + return step_glb_pipeline, glb_tess_engine, False + if serializer == _GLB_SERIALIZER_PYTHON: + tok = tessellator or _GLB_SERIALIZER_TESS[(_GLB_SERIALIZER_PYTHON, fam)][0] + if fam == "step": + step_glb_pipeline = _TESS_TO_STEP_PIPELINE.get(tok, _STEP_GLB_PIPELINE_LIBTESS2) + else: + glb_tess_engine = _TESS_TO_GLB_ENGINE.get(tok, _GLB_TESS_ENGINE_DEFAULT) + return step_glb_pipeline, glb_tess_engine, True + return step_glb_pipeline, glb_tess_engine, False + + +# --------------------------------------------------------------------------- +# B-rep → B-rep (step→ifc, ifc→step) path options. Same shared frontend selector +# as the →GLB rows, but the second axis is a WRITER (no tessellation happens), +# titled "Writer" via the backend. The writer mirrors the serializer 1:1 — each +# code path has exactly one B-rep emitter — so the writer dropdown is +# informational (one entry per serializer), consistent with how cpp→glb pins its +# tessellator. Client (wasm) B-rep writers run in-browser via the adacpp wasm +# writer modules. +_BREP_SERIALIZER_LABELS = { + _GLB_SERIALIZER_CPP: "C++ (adacpp native)", + _GLB_SERIALIZER_PYTHON: "Python (OCC)", + _GLB_SERIALIZER_WASM: "WASM (browser)", +} +# serializer -> (writer token, writer label). One writer per path. +_BREP_SERIALIZER_WRITER = { + _GLB_SERIALIZER_CPP: ("native", "Native (adacpp B-rep)"), + _GLB_SERIALIZER_PYTHON: ("occ", "OCC / ifcopenshell"), + _GLB_SERIALIZER_WASM: ("wasm-native", "Native (WASM)"), +} +_BREP_SERIALIZER_ORDER = (_GLB_SERIALIZER_CPP, _GLB_SERIALIZER_PYTHON, _GLB_SERIALIZER_WASM) + + +def _brep_serializer_options(source_ext: str, target: str) -> list[dict]: + """serializer + dependent ``writer`` options for a B-rep→B-rep row (single-sourced, like + :func:`_glb_serializer_options`). The writer axis is titled "Writer" and mirrors the serializer.""" + serializers = list(_BREP_SERIALIZER_ORDER) + enum_by = {s: [_BREP_SERIALIZER_WRITER[s][0]] for s in serializers} + writer_tokens: list[str] = [] + for s in serializers: + for tok in enum_by[s]: + if tok not in writer_tokens: + writer_tokens.append(tok) + writer_labels = {_BREP_SERIALIZER_WRITER[s][0]: _BREP_SERIALIZER_WRITER[s][1] for s in serializers} + default_ser = serializers[0] + return [ + { + "name": "serializer", + "type": "enum", + "title": "Serializer", + "default": default_ser, + "enum": serializers, + "labels": {s: _BREP_SERIALIZER_LABELS[s] for s in serializers}, + "runtime": {s: ("client" if s in _GLB_CLIENT_SERIALIZERS else "server") for s in serializers}, + "description": ( + f"Conversion code path for →{target.upper()}. 'cpp' = pure-C++ adacpp B-rep writer " + "(fastest, dep-free); 'python' = the OpenCASCADE / ifcopenshell writer; 'wasm' runs " + "the adacpp writer entirely in your browser (no server round-trip)." + ), + }, + { + "name": "tessellator", # wire key kept stable across targets; DISPLAYED as "Writer" + "type": "enum", + "title": "Writer", + "default": enum_by[default_ser][0], + "enum": writer_tokens, + "labels": writer_labels, + "enum_by": enum_by, + "depends_on": "serializer", + "description": "B-rep writer. Mirrors the serializer — each code path has one writer.", + }, + ] + + +def _conversion_path_options(source_ext: str, target: str) -> list[dict]: + """The shared serializer × (tessellator|writer) path options for a (source, target) row. Mesh + targets (glb) get the tessellator axis; B-rep targets (ifc/step) get the writer axis. Single + source of the vocabulary for the frontend selector.""" + t = target.lstrip(".").lower() + if t == "glb": + return _glb_serializer_options(source_ext) + if t in ("ifc", "step"): + return _brep_serializer_options(source_ext, t) + return [] + + +def _brep_writer_is_python(serializer: str | None) -> bool: + """A B-rep→B-rep serializer that routes to the Python/OCC writer (vs the native adacpp emitter). + Client (wasm) serializers never reach the worker, so they are not 'python' here.""" + return serializer == _GLB_SERIALIZER_PYTHON + + def _default_glb_tess_engine() -> str: """Default engine for the non-STEP →GLB (scene) path: ``libtess2`` when adacpp is importable, else the OCC BatchTessellator. OCC's prism tessellation of curved B-spline plates is @@ -2209,15 +2490,16 @@ def _register_ada_loadable() -> None: } # Non-STEP →GLB tessellation engine (xml / ifc / sat / fem / obj / stl → glb). Same engines - # as STEP but driving to_gltf's BatchTessellator stream selector; default OCC (libtess2 opt-in). + # as STEP but driving to_gltf's BatchTessellator stream selector; default libtess2 (adacpp-aware, + # matching the runtime `_default_glb_tess_engine`), degrading to OCC on an adacpp-less pool. glb_tess_engine_option = { "name": "glb_tess_engine", "type": "enum", "default": _GLB_TESS_ENGINE_DEFAULT, "enum": list(_GLB_TESS_ENGINES), "description": ( - "→GLB tessellation engine. 'occ-builtin' (default) is the OpenCASCADE tessellator. " - "'libtess2' is adacpp's OCC-free boundary tessellator — renders curved surfaces OCC " + "→GLB tessellation engine. 'libtess2' (default) is adacpp's OCC-free boundary " + "tessellator — renders curved surfaces OCC " "drops and avoids the OCC optimal-bbox cost on curved-heavy models (per-geom fallback " "to OCC when a geometry isn't yet NGEOM-serializable). 'adacpp-occ' / 'adacpp-cgal' / " "'adacpp-hybrid' use adacpp's taxonomy kernels. Engines needing adacpp fall back to OCC " @@ -2312,8 +2594,17 @@ def _h( step_glb_pipeline=None, glb_tess_engine=None, strict_tess=None, + serializer=None, + tessellator=None, **_kw, ): + # The friendly serializer/tessellator dropdowns resolve into the existing engine + # knobs (single-sourced by _apply_glb_serializer); explicit step_glb_pipeline / + # glb_tess_engine still work when serializer is unset. + step_glb_pipeline, glb_tess_engine, _force_python = _apply_glb_serializer( + _ext, serializer, tessellator, + step_glb_pipeline=step_glb_pipeline, glb_tess_engine=glb_tess_engine, + ) return _via_ada( src, _ext, @@ -2333,10 +2624,12 @@ def _h( # STEP sources: streaming toggle + the STEP engine selector (incl. the OCC # streaming reader). Other →glb sources: the BatchTessellator engine toggle. # strict_tess (fail-on-OCC-fallback) applies to the non-STEP BatchTessellator path. + # The serializer/tessellator dropdowns are added on every →glb row. if ext in {".step", ".stp"}: row_options = glb_options + [step_streamer_option, step_glb_pipeline_option, strict_tess_option] else: row_options = glb_options + [glb_tess_engine_option, strict_tess_option] + row_options = row_options + _glb_serializer_options(ext) elif tgt in ("ifc", "xml") and ext in _FEM_SOURCE_EXTS: row_options = fem_to_objects_options else: @@ -2401,6 +2694,40 @@ def _h(src, on_progress, *, _ext=ext, step=None, field=None, source_uri=None, ** ConverterRegistry.register(ext, "glb", _h) +def _via_ifc_stream_to_glb( + src_path: pathlib.Path, + on_progress: ProgressFn, + *, + glb_tess_engine: str | None = None, + strict_tess: bool | None = None, + force_python: bool = False, +) -> bytes | pathlib.Path: + """IFC → GLB, fully native (adacpp ``stream_ifc_to_glb``: pure-C++ IfcResolver → libtess2 → + merge-by-colour GLB, no ifcopenshell/OCC), with a graceful fallback to the ifcopenshell + ``from_ifc`` → GLB path. The native GLB carries geometry + per-mesh colour + the spatial tree + + names — viewer-equivalent (IFC property sets never live in the GLB; they are fetched on selection). + Falls back when adacpp's native entry is absent, the run raises, or it produces 0 products (e.g. an + IFC of only tessellated face-sets the analytic resolver skips). + + ``force_python`` (serializer='python') bypasses the pure-native path entirely so the chosen + ``glb_tess_engine`` kernel actually drives the ifcopenshell import + BatchTessellator.""" + from ada.config import logger + + from ada.cadit.ifc.native_ifc_to_glb import native_ifc_glb_available, native_ifc_to_glb + + if not force_python and native_ifc_glb_available(): + try: + out_path = pathlib.Path(tempfile.mkstemp(suffix=".glb")[1]) + stats = native_ifc_to_glb(src_path, out_path, on_progress=on_progress) + if stats.get("solids", 0) > 0: + logger.info("native IFC->GLB: %s", stats) + return out_path + logger.info("native IFC->GLB produced 0 products; falling back to from_ifc") + except Exception as exc: # noqa: BLE001 + logger.info("native IFC->GLB failed (%s); falling back to from_ifc", exc) + return _via_ada(src_path, ".ifc", "glb", on_progress, glb_tess_engine=glb_tess_engine, strict_tess=strict_tess) + + def _register_step_stream_exports() -> None: # STEP/STP exports that bypass the full-OCC Assembly (which OOM-kills / times # out on multi-GB CAD assemblies). Registered AFTER _register_ada_loadable so @@ -2425,10 +2752,14 @@ def _h_step(src, on_progress, *, _ext=ext, **_kw): ConverterRegistry.register(ext, "step", _h_step) - def _h_ifc(src, on_progress, *, _ext=ext, **_kw): + def _h_ifc(src, on_progress, *, _ext=ext, serializer=None, tessellator=None, **_kw): + # serializer=python routes to the OCC/ifcopenshell writer; cpp (default) uses the native + # streaming adacpp B-rep emitter. wasm serializers run client-side (never reach here). + if _brep_writer_is_python(serializer): + return _via_ada(src, _ext, "ifc", on_progress) return _via_step_stream_to_ifc(src, on_progress) - ConverterRegistry.register(ext, "ifc", _h_ifc) + ConverterRegistry.register(ext, "ifc", _h_ifc, options=_conversion_path_options(ext, "ifc")) def _h_xml(src, on_progress, *, _ext=ext, **_kw): return _via_step_stream_to_xml(src, on_progress) @@ -2445,10 +2776,43 @@ def _h_xml(src, on_progress, *, _ext=ext, **_kw): _native_ifc2step = False if _native_ifc2step: - def _h_ifc2step(src, on_progress, **_kw): + def _h_ifc2step(src, on_progress, *, serializer=None, tessellator=None, **_kw): + # serializer=python routes to the OCC ifc→step writer; cpp (default) uses the native + # adacpp B-rep reader → AP242 writer. wasm serializers run client-side. + if _brep_writer_is_python(serializer): + return _via_ada(src, ".ifc", "step", on_progress) return _via_ifc_to_step(src, on_progress) - ConverterRegistry.register(".ifc", "step", _h_ifc2step) + ConverterRegistry.register( + ".ifc", "step", _h_ifc2step, options=_conversion_path_options(".ifc", "step") + ) + + # IFC → GLB via the native adacpp IFC pipeline (no ifcopenshell/OCC), overriding the generic + # from_ifc → GLB. Gated on the native verb; degrades to from_ifc per _via_ifc_stream_to_glb's + # own fallback (absent verb / error / 0 products), so this is always safe to register. + try: + from ada.cadit.ifc.native_ifc_to_glb import native_ifc_glb_available + + _native_ifc2glb = native_ifc_glb_available() + except Exception: # noqa: BLE001 + _native_ifc2glb = False + if _native_ifc2glb: + + def _h_ifc2glb(src, on_progress, *, glb_tess_engine=None, strict_tess=None, + serializer=None, tessellator=None, **_kw): + _step_pipe, glb_tess_engine, force_python = _apply_glb_serializer( + ".ifc", serializer, tessellator, + step_glb_pipeline=None, glb_tess_engine=glb_tess_engine, + ) + return _via_ifc_stream_to_glb( + src, on_progress, glb_tess_engine=glb_tess_engine, + strict_tess=strict_tess, force_python=force_python, + ) + + # Preserve the serializer/tessellator + engine options declared on the generic ifc→glb row. + ConverterRegistry.register( + ".ifc", "glb", _h_ifc2glb, options=ConverterRegistry.options_for(".ifc", "glb") + ) def _register_glb_to_mesh() -> None: diff --git a/src/ada/comms/rest/db.py b/src/ada/comms/rest/db.py index ed574567b..576c34faa 100644 --- a/src/ada/comms/rest/db.py +++ b/src/ada/comms/rest/db.py @@ -531,6 +531,78 @@ async def _bump_audit_run_counter( ) +async def reset_audit_cell_for_rerun( + pool: asyncpg.Pool, + run_id: str, + key: str, + target_format: str, + new_job_id: str, +) -> bool: + """Re-point a single audit cell at a fresh conversion job so it can be + re-run in place, keeping the run's counters and total correct. + + One row exists per cell in a run (the dispatcher inserts it ``queued`` and + the worker updates it by ``job_id``). To re-run one cell we: undo the + counter its prior terminal status bumped (so ``failed`` drops when a + previously-failed cell is retried), clear its result fields, attach the new + ``job_id`` and set it back to ``queued``, and reopen the run. The worker's + normal completion path (``update_audit_by_job`` → the counter bump) + then re-increments and re-finishes the run exactly as for a first run. + + Returns False if the cell isn't part of the run (nothing to re-run).""" + async with pool.acquire() as conn: + async with conn.transaction(): + row = await conn.fetchrow( + """ + SELECT id, status FROM audit_log + WHERE audit_run_id = $1 AND key = $2 AND target_format = $3 + ORDER BY id DESC + LIMIT 1 + """, + run_id, + key, + target_format, + ) + if row is None: + return False + prior_col = _AUDIT_RUN_COUNTER_FOR_STATUS.get(row["status"] or "") + await conn.execute( + """ + UPDATE audit_log + SET job_id = $2, status = 'queued', error = NULL, traceback = NULL, + duration_ms = NULL, cpu_user_ms = NULL, cpu_sys_ms = NULL, + peak_rss_kb = NULL, read_bytes = NULL, write_bytes = NULL, + profile_key = NULL, log_key = NULL, ts = NOW() + WHERE id = $1 + """, + row["id"], + new_job_id, + ) + # Undo the prior terminal counter (if any) and reopen the run. The + # column name comes from a closed allowlist so the f-string is safe. + dec = f"{prior_col} = GREATEST({prior_col} - 1, 0)," if prior_col else "" + await conn.execute( + f""" + UPDATE audit_runs + SET {dec} + -- Fold the idle gap since this run last finished into idle_ms + -- (same as extend_audit_run_total). Then when the re-run cell + -- lands and re-finishes the run, the wall-clock duration grows + -- ONLY by the re-run's own delta, not the days-long gap since + -- the original run. RHS reads the pre-update finished_at. + idle_ms = idle_ms + CASE + WHEN status <> 'aborted' AND finished_at IS NOT NULL + THEN GREATEST(0, (EXTRACT(EPOCH FROM (NOW() - finished_at)) * 1000)::bigint) + ELSE 0 END, + status = CASE WHEN status = 'aborted' THEN 'aborted' ELSE 'running' END, + finished_at = CASE WHEN status = 'aborted' THEN finished_at ELSE NULL END + WHERE id = $1 + """, + run_id, + ) + return True + + async def active_audit_summary(pool: asyncpg.Pool) -> dict: """Aggregate state of currently-``running`` audit runs. @@ -1175,6 +1247,32 @@ async def create_corpus( return _corpus_row(row) +async def update_corpus( + pool: asyncpg.Pool, + slug: str, + *, + name: str, + description: str | None, +) -> dict | None: + """Update a live corpus's display name / description. The slug is + immutable — it's baked into the storage prefix (``corpus//``) + and scope URLs, so renaming it would orphan the bucket bytes. + Returns the updated row, or None when the slug isn't live.""" + row = await pool.fetchrow( + """ + UPDATE corpora + SET name = $2, description = $3 + WHERE slug = $1 AND archived_at IS NULL + RETURNING id, slug, name, description, created_at, + created_by, archived_at + """, + slug, + name, + description, + ) + return _corpus_row(row) if row else None + + async def get_corpus_by_slug( pool: asyncpg.Pool, slug: str, @@ -1239,6 +1337,9 @@ def _opt(col: str): "status": r["status"], "note": r["note"], "total": r["total"], + # Parity cells reserved in ``total`` for the auto-validation pass but + # not yet enqueued (0 once the pass dispatches, or for non-auto runs). + "validate_total": _opt("validate_total") or 0, "ok": r["ok"], "failed": r["failed"], "skipped": r["skipped"], @@ -1247,6 +1348,11 @@ def _opt(col: str): # Idle time excluded from the run's active duration (gap before a later # validation pass). The UI shows (finished_at - started_at) - idle_ms. "idle_ms": _opt("idle_ms") or 0, + # Sum of every cell's own duration_ms — the run's active compute time, + # independent of wall clock (which parallel workers compress and a + # single-cell re-run inflates with idle). Recomputes whenever a cell's + # row changes, so a re-run's new timing is reflected immediately. + "cells_duration_ms": _opt("cells_duration_ms") or 0, "force_rebuild": _opt("force_rebuild") or False, "auto_validate": _opt("auto_validate") or False, "parent_run_id": str(parent_run_id) if parent_run_id else None, @@ -1309,21 +1415,31 @@ async def set_audit_run_total( pool: asyncpg.Pool, run_id: str, total: int, + *, + validate_total: int = 0, ) -> None: """Set the dispatched-job count after enqueue completes. If ``total`` is 0 (no jobs to run — empty scope or no viable cells), the run is marked ``finished`` immediately so the UI doesn't show - a perpetually-running row.""" + a perpetually-running row. + + ``validate_total`` reserves that many of the ``total`` cells for the + auto-validation parity pass, which is dispatched later (once the + conversion cells have landed) — so the run's total is complete from + the start. The reservation is consumed by + :func:`consume_audit_run_validation_reserve`.""" await pool.execute( """ UPDATE audit_runs SET total = $2, + validate_total = $3, status = CASE WHEN $2 = 0 THEN 'finished' ELSE status END, finished_at = CASE WHEN $2 = 0 THEN NOW() ELSE finished_at END WHERE id = $1 """, run_id, total, + validate_total, ) @@ -1354,6 +1470,45 @@ async def extend_audit_run_total(pool: asyncpg.Pool, run_id: str, delta: int) -> ) +async def consume_audit_run_validation_reserve( + pool: asyncpg.Pool, + run_id: str, + actual: int, +) -> None: + """Swap a run's reserved validation-cell count for the ``actual`` + number of parity cells enumerated at dispatch time. + + ``total`` was set upfront to conversions + reservation; here it is + corrected by (actual - validate_total) — normally zero, non-zero only + when the scope's files changed between the two enumerations — and the + reservation is cleared. If the corrected total is already met (e.g. + ``actual`` is 0 because the parity enumeration failed or the sources + vanished), the run finishes here since no counter-bump will arrive to + do it. Aborted runs only get their bookkeeping corrected.""" + await pool.execute( + """ + UPDATE audit_runs + SET total = total - validate_total + $2, + validate_total = 0, + finished_at = CASE + WHEN status = 'aborted' THEN finished_at + WHEN ok + failed + skipped >= total - validate_total + $2 + THEN COALESCE(finished_at, NOW()) + ELSE finished_at + END, + status = CASE + WHEN status = 'aborted' THEN 'aborted' + WHEN ok + failed + skipped >= total - validate_total + $2 + THEN 'finished' + ELSE status + END + WHERE id = $1 + """, + run_id, + actual, + ) + + async def delete_audit_run(pool: asyncpg.Pool, run_id: str) -> tuple[bool, list[str]]: """Delete an audit run and its audit_log rows. ``audit_parity`` rows cascade on the run delete; ``audit_log`` is ON DELETE SET NULL, so we remove those @@ -1393,9 +1548,11 @@ async def list_audit_runs( rows = await pool.fetch( f""" SELECT id, seq, scope, worker_pool, trigger, started_at, finished_at, - status, note, total, ok, failed, skipped, created_by, idle_ms, + status, note, total, validate_total, ok, failed, skipped, created_by, idle_ms, force_rebuild, auto_validate, parent_run_id, auto_validate_dispatched_at, - issue_bot_status, issue_bot_last_error, issue_bot_synced_at + issue_bot_status, issue_bot_last_error, issue_bot_synced_at, + (SELECT COALESCE(SUM(duration_ms), 0)::bigint FROM audit_log + WHERE audit_run_id = audit_runs.id) AS cells_duration_ms FROM audit_runs {where} ORDER BY started_at DESC @@ -1410,9 +1567,11 @@ async def get_audit_run(pool: asyncpg.Pool, run_id: str) -> dict | None: row = await pool.fetchrow( """ SELECT id, seq, scope, worker_pool, trigger, started_at, finished_at, - status, note, total, ok, failed, skipped, created_by, idle_ms, + status, note, total, validate_total, ok, failed, skipped, created_by, idle_ms, force_rebuild, auto_validate, parent_run_id, auto_validate_dispatched_at, - issue_bot_status, issue_bot_last_error, issue_bot_synced_at + issue_bot_status, issue_bot_last_error, issue_bot_synced_at, + (SELECT COALESCE(SUM(duration_ms), 0)::bigint FROM audit_log + WHERE audit_run_id = audit_runs.id) AS cells_duration_ms FROM audit_runs WHERE id = $1 """, run_id, @@ -1803,26 +1962,39 @@ async def claim_audit_run_for_issue_bot( async def claim_audit_run_for_auto_validate(pool: asyncpg.Pool): - """Atomically claim the oldest finished ``auto_validate`` run whose - validation pass hasn't been dispatched yet. Stamps - ``auto_validate_dispatched_at`` in the same statement so a concurrent tick - / replica can't double-fire. Returns the claimed run row or ``None``.""" + """Atomically claim the oldest ``auto_validate`` run that is ready for its + validation pass. Two shapes qualify: + + * a run whose parity cells were reserved upfront (``validate_total > 0``) + and whose conversion cells have all landed — it is still ``running`` + because the reserved cells keep the counter-bump finish check from + tripping; + * a pre-reservation run (``validate_total = 0``) that already finished — + its validation extends the total, the legacy behaviour. + + Stamps ``auto_validate_dispatched_at`` in the same statement so a + concurrent tick / replica can't double-fire. Returns the claimed run row + or ``None``.""" row = await pool.fetchrow( """ UPDATE audit_runs SET auto_validate_dispatched_at = NOW() WHERE id = ( SELECT id FROM audit_runs - WHERE status = 'finished' - AND auto_validate = TRUE + WHERE auto_validate = TRUE AND auto_validate_dispatched_at IS NULL - ORDER BY finished_at ASC NULLS LAST + AND ( + (validate_total > 0 AND status = 'running' + AND ok + failed + skipped >= total - validate_total) + OR (validate_total = 0 AND status = 'finished') + ) + ORDER BY started_at ASC NULLS LAST LIMIT 1 FOR UPDATE SKIP LOCKED ) RETURNING id, scope, worker_pool, trigger, started_at, finished_at, - status, note, total, ok, failed, skipped, created_by, - force_rebuild, auto_validate, parent_run_id, + status, note, total, validate_total, ok, failed, skipped, + created_by, force_rebuild, auto_validate, parent_run_id, issue_bot_status, issue_bot_last_error, issue_bot_synced_at """, ) diff --git a/src/ada/comms/rest/handlers.py b/src/ada/comms/rest/handlers.py index 86c8c8970..cf42a33c6 100644 --- a/src/ada/comms/rest/handlers.py +++ b/src/ada/comms/rest/handlers.py @@ -18,6 +18,7 @@ import os import threading +from collections.abc import Awaitable, Callable, Iterable from pathlib import PurePosixPath from ada.comms.fb.fb_server_gen import ServerProcessInfoDC @@ -242,15 +243,17 @@ async def dispatch( storage: Storage, scope: Scope, *, - extra_source_exts: frozenset[str] | None = None, + exts_provider: Callable[[], Awaitable[Iterable[str]]] | None = None, ) -> bytes | None: """Deserialize, dispatch (passing the request's scope to handlers), return serialized response (or None for no-reply). - ``extra_source_exts`` is the set of source extensions advertised by - online capability workers — only LIST_FILE_OBJECTS uses it today - (to surface plug-in formats like .odb in the storage overview). - Other handlers ignore the argument. + ``exts_provider`` lazily yields the set of source extensions + advertised by online capability workers. Only LIST_FILE_OBJECTS + consumes it (to surface plug-in formats like .odb in the storage + overview), so it is resolved *inside* that branch — every other + command (server info, view-file, ...) skips the worker-registry read + entirely, keeping worker-listing off the critical file-listing path. """ message = deserialize_root_message(payload) @@ -259,5 +262,11 @@ async def dispatch( logger.info("REST: unsupported command_type %s", message.command_type) return _error_reply(message, f"command_type {message.command_type} not supported in REST mode") if message.command_type == CommandTypeDC.LIST_FILE_OBJECTS: + extra_source_exts: frozenset[str] = frozenset() + if exts_provider is not None: + try: + extra_source_exts = frozenset(await exts_provider()) + except Exception: + logger.exception("rpc: failed to read worker-advertised exts") return await handler(message, storage, scope, extra_source_exts) return await handler(message, storage, scope) diff --git a/src/ada/comms/rest/migrations/021_audit_run_validate_total.sql b/src/ada/comms/rest/migrations/021_audit_run_validate_total.sql new file mode 100644 index 000000000..45e6aa486 --- /dev/null +++ b/src/ada/comms/rest/migrations/021_audit_run_validate_total.sql @@ -0,0 +1,17 @@ +-- 021_audit_run_validate_total.sql — count the validation pass upfront. +-- +-- ``validate_total`` reserves the auto-validation parity cells inside a +-- run's ``total`` at *initial* dispatch, so the run's cell count is +-- complete from the start instead of growing when the validation pass +-- begins. While the reservation is outstanding the run stays 'running' +-- (the counter-bump finish check compares against the full total); the +-- auto-validate poller claims the run once the conversion cells alone +-- have landed (ok + failed + skipped >= total - validate_total) and the +-- validation dispatch *consumes* the reservation — re-counting the +-- parity cells against the scope's current files and folding any drift +-- into ``total`` — rather than extending it. +-- +-- Runs without auto-validate (and pre-migration rows) keep +-- validate_total = 0 and the old extend-on-finish behaviour. + +ALTER TABLE audit_runs ADD COLUMN validate_total INT NOT NULL DEFAULT 0; diff --git a/src/ada/comms/rest/queue.py b/src/ada/comms/rest/queue.py index 744c7612e..3a8849965 100644 --- a/src/ada/comms/rest/queue.py +++ b/src/ada/comms/rest/queue.py @@ -397,6 +397,49 @@ async def update(self, job_id: str, **fields) -> Job | None: async def _put(self, job: Job) -> None: await self._kv.put(job.job_id, job.to_json()) + # Terminal statuses whose KV entry is disposable once the client has had a + # chance to read the final state. The durable record lives in Postgres + # (audit_log / audit runs) and S3 (derived blobs, profiles) — the KV is only + # a transient progress cache for in-flight polling. + _TERMINAL_STATUSES = frozenset({JOB_STATUS_DONE, JOB_STATUS_ERROR, "cancelled"}) + + async def purge_completed_jobs(self, grace_s: float = 600.0) -> int: + """Drop KV entries for jobs that reached a terminal state more than + ``grace_s`` ago, so the bucket stays small and ``keys()`` scans stay + cheap. Without this, completed job-status entries accumulated forever + (observed: 47k entries), turning every registry scan into a full-bucket + replay that hammered NATS. ``__meta_*`` keys are never touched.""" + if self._kv is None: + return 0 + try: + keys = await self._kv.keys() + except (BucketNotFoundError, Exception): + return 0 + cutoff = time.time() - grace_s + purged = 0 + for key in keys: + if key.startswith(self._META_KEY_PREFIX): + continue + try: + entry = await self._kv.get(key) + except KeyNotFoundError: + continue + except Exception: + continue + try: + job = Job.from_json(entry.value) + except Exception: + continue + if job.status in self._TERMINAL_STATUSES and (job.updated_at or 0.0) < cutoff: + try: + await self._kv.purge(key) + purged += 1 + except Exception: + pass + if purged: + logger.info("queue: purged %d completed job entr(ies) from the KV", purged) + return purged + # --- meta-state helpers ------------------------------------------ # # The shared KV bucket also holds small operational metadata under diff --git a/src/ada/comms/rest/storage_ops.py b/src/ada/comms/rest/storage_ops.py index e0dfeef44..eeafd1d9a 100644 --- a/src/ada/comms/rest/storage_ops.py +++ b/src/ada/comms/rest/storage_ops.py @@ -143,7 +143,9 @@ async def delete_blob_cascade(storage: Storage, scope_obj: Scope, key: str) -> d k, exc, ) - tree_errors.append(f"{k}: {exc}") + # Report only the failed key to the client; the exception detail is + # logged above (avoid leaking backend/stack-trace text in the response). + tree_errors.append(k) return {"deleted": deleted_tree, "errors": tree_errors} # Plain single-blob derived (legacy GLB, meta cache, @@ -152,7 +154,7 @@ async def delete_blob_cascade(storage: Storage, scope_obj: Scope, key: str) -> d await storage.delete(scope_obj, clean) except Exception as exc: logger.exception("storage-op: delete failed for %s", clean) - raise HTTPException(status_code=500, detail=str(exc)) from exc + raise HTTPException(status_code=500, detail="delete failed") from exc return {"deleted": [clean], "errors": []} # Source delete: also reap every derived blob keyed off this @@ -190,8 +192,11 @@ async def delete_blob_cascade(storage: Storage, scope_obj: Scope, key: str) -> d continue if k == clean: logger.exception("storage-op: delete failed for source %s", clean) - raise HTTPException(status_code=500, detail=str(exc)) from exc - errors.append(f"{k}: {exc}") + raise HTTPException(status_code=500, detail="delete failed") from exc + # Report only the failed key to the client; log the exception detail server-side + # (avoid leaking backend/stack-trace text in the response). + logger.warning("storage-op: failed to delete derived %s: %s", k, exc) + errors.append(k) return {"deleted": deleted, "errors": errors} @@ -230,8 +235,10 @@ async def rename_key_cascade( try: await storage.rename(scope_obj, old_key, new_key, overwrite=True) except Exception as exc: + # Full detail logged; return a generic reason so backend/stack-trace text isn't + # exposed in the response (CodeQL py/stack-trace-exposure). logger.exception("storage-op: rename failed for %s -> %s", old_key, new_key) - return {"key": old_key, "reason": str(exc)} + return {"key": old_key, "reason": "rename failed"} live_keys.discard(old_key) live_keys.add(new_key) @@ -274,7 +281,8 @@ async def rename_key_cascade( sk_new, exc, ) - sibling_errors.append(f"{sk_old}: {exc}") + # Report only the failed key to the client; exception detail is logged above. + sibling_errors.append(sk_old) return { "old": old_key, diff --git a/src/ada/comms/rest/subprocess_convert.py b/src/ada/comms/rest/subprocess_convert.py index a19efb884..15e5a5399 100644 --- a/src/ada/comms/rest/subprocess_convert.py +++ b/src/ada/comms/rest/subprocess_convert.py @@ -441,6 +441,33 @@ def _child_progress(stage: str, frac: float) -> None: _move_into_result(os.fspath(out), result_path) else: raise TypeError(f"convert returned {type(out).__name__}, expected bytes or a path") + # Emit per-conversion quality tallies for the parent to fold into convert_meta + # (marker-line channel, same as the C++ [STEPPROF-JSON] profiler). Best-effort: + # a tally failure must never fail an otherwise-successful conversion. + try: + from ada.occ.tessellating import consume_mesh_distortion_stats, consume_tess_fallback_stats + + fb = consume_tess_fallback_stats() + if fb.get("count"): + _sys.stderr.write("[TESSFALLBACK-JSON] " + json.dumps(fb) + "\n") + md = consume_mesh_distortion_stats() + if md.get("distorted_tris"): + _sys.stderr.write("[MESHHEALTH-JSON] " + json.dumps(md) + "\n") + except Exception: + pass + # Triangle tally -> convert_meta["tri_stats"] (regression signal, see tess_stats). + # Native mesh conversions record it directly; for GLB (no tri count in the return) + # parse the output's JSON chunk. Best-effort — never fail a good conversion. + try: + from ada.cadit.step.tess_stats import consume_tri_stats, count_glb_tri_stats + + ts = consume_tri_stats() + if not ts.get("n_tris") and target_format in ("glb", "gltf"): + ts = count_glb_tri_stats(result_path) + if ts.get("n_tris"): + _sys.stderr.write("[TRISTATS-JSON] " + json.dumps(ts) + "\n") + except Exception: + pass _flush_std() os._exit(0) except BaseException as exc: # noqa: BLE001 — propagate verbatim diff --git a/src/ada/comms/rest/worker.py b/src/ada/comms/rest/worker.py index 668be7c08..d6b305c63 100644 --- a/src/ada/comms/rest/worker.py +++ b/src/ada/comms/rest/worker.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import ctypes import functools import os import pathlib @@ -58,6 +59,36 @@ def _scope_of(job: Job) -> Scope: return Scope.shared() +_LIBC_MALLOC_TRIM: object = None # cached glibc malloc_trim, or False when unavailable + + +def _trim_parent_memory() -> None: + """Return glibc's freed-but-retained arena memory to the OS in the long-lived worker PARENT + after each job. The parent forks per conversion; the freed per-job allocations it makes itself + (reading the child's captured log, parsing the marker JSON / cpp profile, building convert_meta, + the metrics-sample lists) pile up in glibc's arena free-lists rather than returning to the OS, so + parent RSS creeps up across a run (measured on a 23h prod worker: 218 MB fresh -> 540-840 MB idle, + higher mid-run). Because every conversion is a fork, the child INHERITS the parent's address space + (COW, counted in the child's RSS), so a bloated parent lifts EVERY conversion's baseline — enough + to push a big-model fork (469826) over the per-job memory watchdog, and the parent+child sum over + the 6 GiB pod limit (the run-90 pod OOM / Exit 137). Trimming after each job keeps the parent flat. + glibc-only; a best-effort no-op elsewhere. Disable with ADA_WORKER_NO_MALLOC_TRIM.""" + global _LIBC_MALLOC_TRIM + if _LIBC_MALLOC_TRIM is None: + if os.environ.get("ADA_WORKER_NO_MALLOC_TRIM"): + _LIBC_MALLOC_TRIM = False + else: + try: + _LIBC_MALLOC_TRIM = ctypes.CDLL("libc.so.6").malloc_trim + except (OSError, AttributeError): + _LIBC_MALLOC_TRIM = False + if _LIBC_MALLOC_TRIM: + try: + _LIBC_MALLOC_TRIM(0) + except Exception: # noqa: BLE001 — memory hygiene must never fail a job + pass + + # How long the pull-subscriber waits per fetch round before re-issuing. # Short enough to react to shutdown; long enough not to spam NATS. FETCH_TIMEOUT = 5.0 @@ -82,6 +113,21 @@ def _scope_of(job: Job) -> Scope: # stays free to fire these. IN_PROGRESS_REFRESH_SECONDS = 30 +# Liveness heartbeat. The worker touches this file whenever its JetStream pull loop iterates +# (idle / between jobs) or an in-flight conversion reports progress. A k8s livenessProbe checks the +# file's mtime is fresh: if the pull loop stalls — e.g. the durable consumer wedged after a NATS +# restart, leaving the pod "Running" but no longer fetching (num_waiting=0) — the file goes stale and +# k8s restarts the pod, instead of it sitting silently broken while jobs pile up unconsumed. +WORKER_LIVENESS_FILE = os.environ.get("WORKER_LIVENESS_FILE", "/tmp/worker-alive") + + +def _touch_liveness() -> None: + try: + with open(WORKER_LIVENESS_FILE, "w") as fh: + fh.write(str(time.time())) + except OSError: + logger.debug("worker: liveness touch failed", exc_info=True) + # Per-source-suffix sidecar files that the worker co-downloads next to # the main payload so format-specific readers find them by basename. # Keep this conservative — a 404 on an absent sibling is silent, but @@ -201,6 +247,95 @@ def _convert_meta_for(job: "Job", env_overrides: dict | None) -> dict | None: return meta or None +def _attach_cpp_profiles(convert_meta: dict | None, log_bytes: bytes | None) -> None: + """Parse the adacpp pipeline profiler's machine-readable summaries out of the + captured child output into ``convert_meta["cpp_profile"]``. + + When the ``profile_conversions`` toggle is on, the child runs with + ``ADACPP_STEP_PROFILE=1`` and each instrumented C++ pipeline prints ONE + ``[STEPPROF-JSON] {...}`` line at teardown (phase wall/RSS, VmHWM peak, + per-solid stats, parallelism/IO pressure, per-thread utilisation). Attaching + them to convert_meta puts the C++ side in the audit Metrics panel with the + same visibility as the Python timings. No-op when profiling was off (no + marker lines) or the log is empty.""" + if not isinstance(convert_meta, dict) or not log_bytes: + return + import json + + marker = b"[STEPPROF-JSON] " + profiles: list[dict] = [] + for line in log_bytes.splitlines(): + i = line.find(marker) + if i < 0: + continue + try: + profiles.append(json.loads(line[i + len(marker) :].decode("utf-8", "replace"))) + except (ValueError, UnicodeDecodeError): + continue # a torn/interleaved line must not fail the job + if profiles: + convert_meta["cpp_profile"] = profiles + + # Per-conversion quality flags emitted by the child (subprocess_convert). Currently the + # NGEOM(libtess2/adacpp)->OCC fallback tally — a conversion that silently completed on OCC + # instead of the selected stream kernel. Surfaced as a cell flag in the audit grid. + fb_marker = b"[TESSFALLBACK-JSON] " + for line in log_bytes.splitlines(): + i = line.find(fb_marker) + if i < 0: + continue + try: + fb = json.loads(line[i + len(fb_marker) :].decode("utf-8", "replace")) + except (ValueError, UnicodeDecodeError): + continue + if isinstance(fb, dict) and fb.get("count"): + convert_meta["occ_fallback"] = fb # {count, reasons, geoms} + break + + mh_marker = b"[MESHHEALTH-JSON] " + for line in log_bytes.splitlines(): + i = line.find(mh_marker) + if i < 0: + continue + try: + mh = json.loads(line[i + len(mh_marker) :].decode("utf-8", "replace")) + except (ValueError, UnicodeDecodeError): + continue + if isinstance(mh, dict) and mh.get("distorted_tris"): + convert_meta["mesh_flags"] = mh # {n_tris, distorted_tris, distorted_frac} + break + + # Triangle tally — total output triangles (+ primitive/solid counts when known). The primary + # run-to-run regression signal: a tessellation-density change or a dropped solid moves n_tris. + ts_marker = b"[TRISTATS-JSON] " + for line in log_bytes.splitlines(): + i = line.find(ts_marker) + if i < 0: + continue + try: + ts = json.loads(line[i + len(ts_marker) :].decode("utf-8", "replace")) + except (ValueError, UnicodeDecodeError): + continue + if isinstance(ts, dict) and ts.get("n_tris"): + convert_meta["tri_stats"] = ts # {n_tris, engine?, n_primitives?, n_solids?, ...} + break + + # Geometry health — faces with a real trim boundary that tessellated to zero triangles (silently + # dropped geometry, e.g. the SURFACE_OF_LINEAR_EXTRUSION drops). Flagged in the audit grid so this + # class of bug is caught without visual inspection. + gh_marker = b"[GEOMHEALTH-JSON] " + for line in log_bytes.splitlines(): + i = line.find(gh_marker) + if i < 0: + continue + try: + gh = json.loads(line[i + len(gh_marker) :].decode("utf-8", "replace")) + except (ValueError, UnicodeDecodeError): + continue + if isinstance(gh, dict) and gh.get("dropped_faces"): + convert_meta["geom_health"] = gh # {dropped_faces, total_faces} + break + + def _capture_worker_packages() -> list[dict]: """Snapshot the worker env's installed packages — the conda-meta manifest (authoritative for occt / pythonocc-core / ada-cpp / ifcopenshell / numpy …) @@ -1161,6 +1296,10 @@ async def _process_one( # sibling jobs / the parent worker keep their pristine env. profile_enabled = False env_overrides: dict[str, str] = {} + # Initialised here (not only inside the ``db_pool is not None`` block below) so a worker + # that came up without a DB pool — e.g. it raced Postgres during a restart — still converts + # with code defaults instead of crashing every job with UnboundLocalError on ``timeout_s``. + timeout_s: float | None = None if db_pool is not None: async def _read_bool_setting(key: str) -> str | None: @@ -1172,6 +1311,14 @@ async def _read_bool_setting(key: str) -> str | None: v = await _read_bool_setting("profile_conversions") profile_enabled = (v or "").strip().lower() in {"1", "true", "yes", "on"} + if profile_enabled: + # The C++ sibling of the cProfile artefact: adacpp's env-gated + # [STEPPROF] pipeline profiler (phase wall times, RSS at phase + # boundaries, VmHWM peak, per-solid stats, parallelism/IO + # pressure) prints to stderr, which the captured job Log keeps. + # Applied inside the child fork only, so sibling jobs and the + # parent worker keep their pristine env. + env_overrides["ADACPP_STEP_PROFILE"] = "1" # Optional per-job wall-clock budget. Empty / 0 / non- # numeric leaves the watchdog off so legitimately-long @@ -1181,7 +1328,6 @@ async def _read_bool_setting(key: str) -> str | None: # convert subprocess after the deadline and SIGKILLs # 30 s later if it's still alive. timeout_minutes_raw = await _read_bool_setting("conversion_timeout_minutes") - timeout_s: float | None = None try: tm = float((timeout_minutes_raw or "").strip()) if tm > 0: @@ -1293,6 +1439,7 @@ async def _read_bool_setting(key: str) -> str | None: async def _on_progress(stage: str, frac: float) -> None: nonlocal last_kv_write + _touch_liveness() # a long conversion blocks the pull loop; progress keeps liveness fresh now = time.monotonic() if now - last_kv_write < 0.25 and frac < 1.0: return @@ -1307,6 +1454,7 @@ async def _on_progress(stage: str, frac: float) -> None: # behind for post-mortem instead of an empty metrics_samples # column. async def _on_sample(sample: ConvertSample) -> None: + _touch_liveness() # fires every ~2s during a subprocess conversion if db_pool is None: return try: @@ -1556,6 +1704,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await _audit_done( db_pool, @@ -1647,6 +1796,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await _audit_done( db_pool, @@ -1673,6 +1823,7 @@ async def _cancel_check() -> bool: metrics = dict(iresult.final_metrics) metrics["profile_key"] = await _maybe_upload_profile_bytes(iresult.profile_bytes) metrics["log_key"] = await _maybe_upload_log_bytes(iresult.log_bytes) + _attach_cpp_profiles(convert_meta, iresult.log_bytes) metrics["convert_meta"] = convert_meta await queue.update(job_id, status=JOB_STATUS_DONE, stage="ready", progress=1.0, error=None) @@ -1691,14 +1842,61 @@ async def _cancel_check() -> bool: pass +def _warm_convert_imports() -> None: + """Pre-import the heavy CAD C-extensions in the worker PARENT so every + ``os.fork()``ed conversion child (see subprocess_convert) inherits them + copy-on-write instead of re-importing per job. + + Without this, an IFC child faults in ifcopenshell(.geom) and a SAT child + faults in OCC.Core — hundreds of MB of ``.so`` pages — on EVERY job; on a + cold/pressured page cache that measured ~20-40s of near-zero-CPU I/O-wait + per conversion (STEP's native adacpp reader was unaffected). Pre-importing + keeps the pages referenced by the long-lived parent and out of every child's + hot path. Per-module guard: a slim/scoped pool lacking a backend just skips it. + + NOTE: the durable fix is routing IFC/SAT conversions through adacpp + the + native C++ IFC reader/writer so these deps aren't loaded at all (native STEP + already is). Until every target format is wired natively, IFC->{stl,obj,xml} + and SAT still go through ada.from_ifc / ada.from_acis; this removes their + per-fork re-import tax in the meantime. + """ + import importlib + + for mod, why in ( + ("ada.cadit.ifc.store", "ifcopenshell + ifcopenshell.geom"), + ("ada.occ.tessellating", "OCC.Core tessellation + backends"), + ): + t0 = time.perf_counter() + try: + importlib.import_module(mod) + except Exception as exc: # scoped/slim pool without this backend — skip + logger.info("worker: warm-import skipped %s (%s): %s", mod, why, exc) + continue + logger.info("worker: warm-imported %s (%s) in %.2fs", mod, why, time.perf_counter() - t0) + + async def _run() -> None: + # Make the worker's own lifecycle logs visible. The "ada" logger otherwise + # inherits root's WARNING level, which silently drops every worker: INFO + # line (booting / connected / registered / subscribing / ready) — leaving a + # healthy idle worker indistinguishable from a hung one in `kubectl logs`. + # This is the worker process only; the viewer configures its own logging. + # ADA_WORKER_LOG_LEVEL overrides (e.g. WARNING to quiet a chatty pool). + from ada.config import configure_logger + + configure_logger() # attach the stdout StreamHandler (no-op if already attached) + logger.setLevel(os.environ.get("ADA_WORKER_LOG_LEVEL", "INFO").upper()) + settings = load_settings() if settings.queue.url is None: raise SystemExit("ADA_VIEWER_NATS_URL not set; nothing for the worker to do") + logger.info("worker: booting capabilities=%s", os.environ.get("ADA_WORKER_CAPABILITIES", "base")) storage = Storage.from_settings(settings) queue = JobQueue(settings.queue) + logger.info("worker: connecting to NATS subject=%s", settings.queue.subject) await queue.connect() + logger.info("worker: connected to NATS") # Optional importer hook: capability workers built FROM the base # image often need to populate the connection-spec registry (or @@ -1899,6 +2097,15 @@ async def _publish_registration() -> None: ) sub = await queue.pull_subscribe(primary_capability) + # Warm the heavy CAD imports in this (parent) process before the per-job fork + # loop below, so forked children inherit them copy-on-write instead of paying + # a cold re-import per conversion. Base pool only — capability pools + # (weld-gen/abaqus) run foreign images with their own deps. Run in a thread so + # a slow cold import (OCC/ifcopenshell off a cold page cache) doesn't stall the + # event loop's NATS keepalive while the worker is still starting up. + if "base" in {c.lower() for c in capabilities}: + await asyncio.get_running_loop().run_in_executor(None, _warm_convert_imports) + stop = asyncio.Event() def _signal_handler() -> None: @@ -1932,8 +2139,10 @@ async def _heartbeat_loop() -> None: # Keep the parameter on _process_one for now (callers may still # pass it) but no longer create one here. logger.info("worker: ready, polling %s", settings.queue.subject) + _touch_liveness() # seed the heartbeat before the first fetch so the probe has a fresh mtime try: while not stop.is_set(): + _touch_liveness() # each pull round — a stalled fetch lets this go stale -> livenessProbe restart try: msgs = await sub.fetch(batch=FETCH_BATCH, timeout=FETCH_TIMEOUT) except asyncio.TimeoutError: @@ -2066,6 +2275,9 @@ async def _keep_alive(m=msg, jid=job_id) -> None: except Exception: pass await msg.ack() + # Release the freed-but-retained arena memory this job left in the parent, so it + # doesn't accumulate across the run and inflate the next conversion's fork baseline. + _trim_parent_memory() finally: heartbeat_task.cancel() try: diff --git a/src/ada/config.py b/src/ada/config.py index 325b4527e..e8d90540e 100644 --- a/src/ada/config.py +++ b/src/ada/config.py @@ -148,6 +148,22 @@ class Config: ConfigEntry("import_raise_exception_on_failed_advanced_face", bool, False), ], ), + ConfigSection( + "cad", + [ + # Lazy shape store: keep imported CAD solids as compact blobs (NGEOM + # buffers from the native readers / pickled ada.geom otherwise) in a + # shared ShapeStore, minting ShapeProxy objects that hydrate geometry + # on demand (weakref-cached). ~5x lower resident memory on large STEP + # imports (a 778 MB / 7291-solid STEP: 2.6 GB -> ~0.5 GB settled). On by default; set + # ADA_CAD_LAZY_SHAPE_STORE=false to materialise eager Shapes as before. + ConfigEntry("lazy_shape_store", bool, True, required=False), + # Compress stored blobs (zlib-1, ~3x on B-rep buffers) at the cost of a + # decompress copy per hydration/fast-path access — trades the zero-copy + # property for a smaller resident floor. + ConfigEntry("shape_store_compress", bool, False, required=False), + ], + ), ConfigSection( "occ_tess", [ diff --git a/src/ada/core/utils.py b/src/ada/core/utils.py index 3943cd6ff..94322a388 100644 --- a/src/ada/core/utils.py +++ b/src/ada/core/utils.py @@ -197,8 +197,10 @@ def to_real(v) -> float | list[float]: def round_array(arr: np.ndarray) -> np.ndarray: - # roundoff only on nonzero entries, zeros stay exact - mask = arr != 0 - out = arr.copy() - out[mask] = np.vectorize(roundoff)(arr[mask]) - return out + # Same roundoff (Decimal, HALF_EVEN) per element — replacing it with + # np.round shifts borderline values enough to drop ~59 of 104k FEM shells + # in plate conversion, so the semantics must stay. Every caller passes a + # 3-vector, where constructing an np.vectorize per call was ~70% of the + # cost (637k calls on a 100k-shell model spent ~28s here) — a plain loop + # keeps the exact values at a fraction of the overhead. + return np.array([roundoff(x) if x != 0 else x for x in np.asarray(arr, dtype=float)], dtype=float) diff --git a/src/ada/core/vector_transforms.py b/src/ada/core/vector_transforms.py index e5bff1a68..dabaffc64 100644 --- a/src/ada/core/vector_transforms.py +++ b/src/ada/core/vector_transforms.py @@ -456,3 +456,114 @@ def compute_orientation_vec( # Convert back to tuples for caching return tuple(xvec), tuple(yvec), tuple(zvec) + + +def _round_rows_unique(a: np.ndarray) -> np.ndarray: + """round_array over the rows of ``a`` (m,3), Decimal-rounding each UNIQUE row once. + + FEM meshes repeat orientations heavily (co-planar plate patches share their + frame), so deduplicating before the per-component ``roundoff`` keeps the exact + scalar semantics at a fraction of the Decimal cost.""" + from ada.core.utils import roundoff + + uniq, inv = np.unique(a, axis=0, return_inverse=True) + out = np.empty_like(uniq) + for i in range(uniq.shape[0]): + row = uniq[i] + out[i] = [roundoff(x) if x != 0 else x for x in row] + return out[inv.reshape(-1)] + + +def shell_orientations_bulk(pts: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Vectorized orientation math for ``m`` flat FEM shell k-gons (m, k, 3). + + Replicates, array-wise and in the same floating-point operation order, the + scalar chain CurvePoly2d.from_fem_shell runs per element: + ``normal_to_points_in_plane`` -> ``calc_yvec`` -> + ``compute_orientation_vec(x, y, z)`` -> ``compute_orientation_vec(x, None, z)`` + (each vector normalized once on construction and once inside + compute_orientation_vec, then Decimal-rounded via round_array — see + :func:`_round_rows_unique`). + + Returns ``(ok, rot1, rotf)``: ``ok (m,)`` False for rows the scalar path must + handle (duplicate points, (near-)parallel edge vectors, zero-length axes — + the scalar code has dedupe/rescan branches there), ``rot1``/``rotf`` (m,3,3) + with rows (xdir, ydir, zdir) — the projection and final frames. + """ + pts = np.asarray(pts, dtype=float) + m, k, _ = pts.shape + + def _cross(a, b): + # component order identical to normal_to_points_in_plane / fast_cross + out = np.empty_like(a) + out[:, 0] = a[:, 1] * b[:, 2] - a[:, 2] * b[:, 1] + out[:, 1] = a[:, 2] * b[:, 0] - a[:, 0] * b[:, 2] + out[:, 2] = a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0] + return out + + def _norm(a): + # np.linalg.norm(v) for a 1-D length-3 vector == sqrt(v.v), summed in + # component order + return np.sqrt(a[:, 0] * a[:, 0] + a[:, 1] * a[:, 1] + a[:, 2] * a[:, 2]) + + # normal_to_points_in_plane: v1 = p3 - p1, v2 = p2 - p1, normal = v1 x v2 + v1 = pts[:, 2] - pts[:, 0] + v2 = pts[:, 1] - pts[:, 0] + normal = _cross(v1, v2) + n_len = _norm(normal) + v1_len = _norm(v1) + v2_len = _norm(v2) + + # Degenerate-row escape (handled by the scalar path's dedupe / parallel-rescan + # branches): any duplicated corner, edge vectors (near-)parallel, or a + # zero-length first edge. The parallel test is a conservative superset of the + # scalar ``is_parallel`` (sin(angle) < tol): escaping a borderline row to the + # scalar path only costs time, never correctness. + dup = np.zeros(m, dtype=bool) + for i in range(k): + for j in range(i + 1, k): + dup |= np.all(pts[:, i] == pts[:, j], axis=1) + denom = v1_len * v2_len + # sin(angle) == |v1 x v2| / (|v1||v2|); escape at 2x the scalar is_parallel + # tolerance so every row the scalar rescan branch would touch goes scalar. + from ada.config import Config + + par_tol = 2.0 * float(Config().general_point_tol) + near_parallel = n_len <= par_tol * np.where(denom > 0, denom, 1.0) + x_raw = pts[:, 1] - pts[:, 0] + x_len = _norm(x_raw) + ok = ~(dup | near_parallel | (denom == 0) | (x_len == 0) | (n_len == 0)) + + safe = lambda d: np.where(d == 0, 1.0, d) # noqa: E731 - masked rows are already not-ok + n_hat = normal / safe(n_len)[:, None] + x_hat = x_raw / safe(x_len)[:, None] + y_raw = _cross(n_hat, x_hat) # calc_yvec(x, z) == fast_cross(z, x) + y_len = _norm(y_raw) + # compute_orientation_vec rebuilds the frame from a global reference when a + # derived yvec degenerates (norm < 1e-9) — those rows must go scalar. + ok &= y_len >= 1e-9 + y_hat = y_raw / safe(y_len)[:, None] + + # compute_orientation_vec(x_hat, y_hat, n_hat): all three supplied -> each is + # re-normalized (Direction.get_normalized divides by its own norm again) and + # Decimal-rounded. + def _renorm_round(a): + ln = _norm(a) + return _round_rows_unique(a / safe(ln)[:, None]) + + x1 = _renorm_round(x_hat) + y1 = _renorm_round(y_hat) + z1 = _renorm_round(n_hat) + + # compute_orientation_vec(x1, None, z1): y = calc_yvec(x1, z1) = z1 x x1, then + # all three re-normalized + rounded. + yf_raw = _cross(z1, x1) + yf_len = _norm(yf_raw) + ok &= yf_len >= 1e-9 + xf = _renorm_round(x1) + yf = _round_rows_unique(yf_raw / safe(yf_len)[:, None]) + zf = _renorm_round(z1) + + rot1 = np.stack([x1, y1, z1], axis=1) + rotf = np.stack([xf, yf, zf], axis=1) + return ok, rot1, rotf diff --git a/src/ada/factories.py b/src/ada/factories.py index 58213c29a..6a5ac2f90 100644 --- a/src/ada/factories.py +++ b/src/ada/factories.py @@ -45,9 +45,18 @@ def from_pickle(pickle_file: str | os.PathLike) -> Assembly: def from_ifc( - ifc_file: os.PathLike | ifcopenshell.file, units=Units.M, name="Ada", cad_config: "CadConfig | None" = None + ifc_file: os.PathLike | ifcopenshell.file, + units=Units.M, + name="Ada", + cad_config: "CadConfig | None" = None, + reader: Literal["ifcopenshell", "native"] | None = None, ) -> Assembly: - """Create an Assembly object from an IFC file.""" + """Create an Assembly object from an IFC file. + + ``reader="native"`` uses adacpp's pure-C++ IFC reader (no ifcopenshell/OCC) to build a + geometry-shapes tree — pairs with ``Assembly.to_ifc(writer="native")`` for a fully native + round-trip. Default (``ifcopenshell``) is the full typed reader (Beam/Plate/Pipe/...). + """ if isinstance(ifc_file, (os.PathLike, str)): ifc_file = pathlib.Path(ifc_file).resolve().absolute() logger.info(f'Reading "{ifc_file.name}"') @@ -55,7 +64,7 @@ def from_ifc( logger.info("Reading IFC file object") a = Assembly(units=units, name=name, cad_config=cad_config) - a.read_ifc(ifc_file) + a.read_ifc(ifc_file, reader=reader) return a @@ -70,7 +79,7 @@ def from_step( colour=None, opacity: float = 1.0, include_shells: bool = False, - reader: Literal["occ", "stream", "auto", "tolerant"] | None = None, + reader: Literal["occ", "stream", "auto", "tolerant", "native"] | None = None, product_tree: bool = False, ) -> Assembly: """Create an Assembly object from a STEP file. diff --git a/src/ada/fem/formats/mesh_faces.py b/src/ada/fem/formats/mesh_faces.py index b8130abed..e971714c4 100644 --- a/src/ada/fem/formats/mesh_faces.py +++ b/src/ada/fem/formats/mesh_faces.py @@ -56,13 +56,20 @@ def from_value(cls, value) -> "MergeStrategy": @dataclass class FaceData: - """A single planar CAD face, as raw data (no Plate object).""" + """A single CAD face, as raw data (no Plate object). + + Planar faces carry their ``outline``; the analytic strategies (SURFACE / + PANEL) additionally emit recognised curved patches with ``geom_face`` set + to the analytic ``ada.geom`` AdvancedFace (trimmed cylinder / B-spline + panel) — ``outline`` is empty for those and consumers that can only take + polygons must request ``allow_analytic=False`` instead.""" name: str outline: np.ndarray # (k, 3) global coordinates normal: np.ndarray # (3,) unit normal material: str thickness: float + geom_face: object | None = None # ada.geom.surfaces.AdvancedFace for analytic patches # ── substrate ─────────────────────────────────────────────────────────────── @@ -1163,6 +1170,102 @@ def iter_fem_analytic_faces( yield _facet_flat_face(prims, j) +def _analytic_face_data( + fem, + ndigits: int, + *, + reconstruct_curved: bool, + allow_analytic: bool, + angle_tol: float = 30.0, + min_patch_quads: int = 12, +) -> Iterator[FaceData]: + """SURFACE/PANEL strategies as :class:`FaceData` (the plate-metadata form). + + Same patch machinery as :func:`iter_fem_analytic_faces`, with one addition it + doesn't need: every analytic face must carry a valid (material, thickness) + pair — the analytic iterator feeds a surface model where that metadata is + irrelevant, but a FaceData consumer builds plates from it. Cylinder patches + are therefore subdivided by (material, thickness) before fitting; a tube + whose wall thickness changes mid-length becomes one analytic face per + uniform segment. Patches that fail the fit — or all of them when + ``allow_analytic`` is False — fall to the coplanar flat merge, so geometry + is never dropped.""" + prims = _combined_shell_primitives(fem) + if prims is None or len(prims) == 0: + return + + patches = list(_surface_patches(prims, angle_tol, ndigits)) + patch_cls = [(pt, classify_patch(prims, pt) if len(pt) >= min_patch_quads else "planar") for pt in patches] + + consumed_elids: set = set() + if reconstruct_curved: + # Structured-quad B-spline pass (PANEL): per quad block, split into uniform + # (material, thickness) sub-blocks so each recovered panel face carries + # plate metadata; cylinder patches stay analytic below. + cyl_elids = {_elid_of(prims.names[j]) for pt, c in patch_cls if c == "cylinder" for j in pt} + for blk in _shell_blocks(fem): + if blk.conn.shape[1] != 4: + continue + groups: dict[tuple, list[int]] = {} + for r in range(blk.conn.shape[0]): + groups.setdefault((blk.materials[r], round(float(blk.thicknesses[r]), ndigits)), []).append(r) + for (mat, _tq), rows in groups.items(): + idx = np.asarray(rows, dtype=np.int64) + sub = _ShellBlock( + coords=blk.coords, + conn=blk.conn[idx], + el_ids=blk.el_ids[idx], + materials=[blk.materials[r] for r in rows], + thicknesses=blk.thicknesses[idx], + ) + t = float(blk.thicknesses[rows[0]]) + for face, panel_elids in _reconstruct_curved_panels( + sub, cyl_elids, ndigits, angle_tol, min_patch_quads + ): + if allow_analytic: + yield FaceData( + f"panel_e{min(panel_elids)}", np.empty((0, 3)), np.zeros(3), mat, t, geom_face=face + ) + consumed_elids.update(panel_elids) + # allow_analytic=False: leave the elements in the flat remainder. + + cyl_prims: set = set() + if allow_analytic: + for patch, cls in patch_cls: + if cls != "cylinder": + continue + groups2: dict[tuple, list[int]] = {} + for j in patch: + if _elid_of(prims.names[j]) in consumed_elids: + continue + groups2.setdefault((prims.mats[j], round(float(prims.ts[j]), ndigits)), []).append(j) + for (mat, _tq), sub_patch in groups2.items(): + if len(sub_patch) < min_patch_quads: + continue # too small a uniform segment — flat merge handles it + cf = fit_cylinder_params(prims, sub_patch) + if cf is None: + continue + # Joint-cut trimmed faces: bounded to the patch's real extent (a brace + # cut at a joint stops at the cut, not the full tube). Each edge carries + # a UV pcurve, which the IFC B-rep emitter now writes as + # IfcSurfaceCurve/IfcPcurve and the reader recovers — so these round-trip. + # Fall back to the full seam-template tube only if the trim won't resolve. + faces = cylinder_trim_faces(prims, sub_patch, cf, ndigits) or cylinder_fit_to_faces(cf) + t = float(prims.ts[sub_patch[0]]) + base = _elid_of(prims.names[sub_patch[0]]) + for i, face in enumerate(faces): + yield FaceData(f"cyl_e{base}_{i}", np.empty((0, 3)), np.zeros(3), mat, t, geom_face=face) + cyl_prims.update(sub_patch) + + remainder = [ + j + for j in range(len(prims)) + if j not in cyl_prims and (not consumed_elids or _elid_of(prims.names[j]) not in consumed_elids) + ] + if remainder: + yield from _coplanar_subset(prims, remainder, ndigits) + + @dataclass class _Tube: """One tube member for the solids path: a fitted cylinder's mid-surface radius ``r`` + wall @@ -1512,7 +1615,12 @@ def classify_patch(prims: "_Primitives", patch: list[int], *, plane_tol: float = def faces_from_fem( - fem, strategy=MergeStrategy.COPLANAR, ndigits: int = 6, *, max_dev: float | None = None + fem, + strategy=MergeStrategy.COPLANAR, + ndigits: int = 6, + *, + max_dev: float | None = None, + allow_analytic: bool = True, ) -> Iterator[FaceData]: """Yield merged CAD faces for a single ``FEM`` mesh (one part). @@ -1523,12 +1631,28 @@ def faces_from_fem( ``PLANAR`` grows flat patches (within ``max_dev``, auto if None) and emits one flat face per patch — the near-term FEM→CAD plate-count reducer that recovers large flat panels coplanar's exact bucketing misses and piecewise-flattens curved - skin. ``SURFACE`` (curved → one B-spline face) is not wired into the writer yet.""" + skin. + + ``SURFACE`` runs the analytic auto-detect over the whole mesh: cylinder + patches (split by material+thickness so each face carries valid plate + metadata) emit as trimmed analytic AdvancedFaces via ``geom_face``; the + remainder falls to the coplanar merge. ``PANEL`` adds the structured-quad + B-spline curved-panel pass on top (its grid recovery can over-merge folded + regions — see :func:`iter_fem_analytic_faces` — so it stays opt-in). + ``allow_analytic=False`` keeps those strategies polygon-only for consumers + that can't express curved surfaces (Genie XML): recognised patches then + merge as their coplanar flats instead — geometry is never dropped.""" strategy = MergeStrategy.from_value(strategy) - if strategy in (MergeStrategy.SURFACE, MergeStrategy.PANEL): - raise NotImplementedError(f"merge strategy {strategy.value!r} not yet wired into the vectorized face source") if fem is None or len(fem.elements) == 0: return + if strategy in (MergeStrategy.SURFACE, MergeStrategy.PANEL): + yield from _analytic_face_data( + fem, + ndigits, + reconstruct_curved=strategy == MergeStrategy.PANEL, + allow_analytic=allow_analytic, + ) + return for blk in _shell_blocks(fem): prims = _block_primitives(blk) if len(prims) == 0: @@ -1552,16 +1676,24 @@ def faces_from_fem( def iter_faces( - part, strategy=MergeStrategy.COPLANAR, ndigits: int = 6, *, max_dev: float | None = None + part, + strategy=MergeStrategy.COPLANAR, + ndigits: int = 6, + *, + max_dev: float | None = None, + allow_analytic: bool = True, ) -> Iterator[FaceData]: """Yield merged CAD faces for every FEM mesh under ``part`` (Part or Assembly). Object-free: walks each sub-part's array-backed shell mesh, never building - Plate/Elem objects. + Plate/Elem objects. ``allow_analytic=False`` keeps the SURFACE/PANEL + strategies polygon-only (see :func:`faces_from_fem`). """ parts = part.get_all_parts_in_assembly(include_self=True) if hasattr(part, "get_all_parts_in_assembly") else [part] for p in parts: - yield from faces_from_fem(getattr(p, "fem", None), strategy, ndigits, max_dev=max_dev) + yield from faces_from_fem( + getattr(p, "fem", None), strategy, ndigits, max_dev=max_dev, allow_analytic=allow_analytic + ) def _coplanar_block(prims: _Primitives, ndigits: int) -> Iterator[FaceData]: diff --git a/src/ada/fem/formats/utils.py b/src/ada/fem/formats/utils.py index e527159e4..8da49dbf4 100644 --- a/src/ada/fem/formats/utils.py +++ b/src/ada/fem/formats/utils.py @@ -568,13 +568,83 @@ def convert_shell_elem_to_plates( def convert_part_shell_elements_to_plates(p: Part) -> Plates: + """Shell elements -> Plates, with the per-element orientation math vectorized. + + A gather pass mirrors :func:`convert_shell_elem_to_plates` exactly (section + guards + warnings, material consolidation, coplanarity split of warped + quads, the tri-branch material quirk and its try/except), producing one + entry per output plate in element order. The geometry then runs once per + polygon arity through :meth:`CurvePoly2d.from_fem_shells_batch` — the same + floating-point ops as ``from_fem_shell``, batched — which on a 100k-shell + model removes ~1/3 of the FEM->concept wall time. Rows the bulk math + escapes (degenerate corners/edges) fall back to the scalar constructor. + """ + import numpy as np + + from ada import Plate + from ada.api.curves import CurvePoly2d + from ada.base.types import GeomRepr + from ada.core.vector_utils import is_coplanar + # One shared material cache for every shell in the part — see the note # in convert_shell_elem_to_plates on why a per-element dict was O(N²). mat_dict: dict = {} - return Plates( - list(chain.from_iterable(convert_shell_elem_to_plates(sh, p, mat_dict) for sh in p.fem.elements.shell)), - parent=p, - ) + + # (name, pts (k,3), thickness, material, catch_errors) — one per output plate. + entries: list[tuple] = [] + for elem in p.fem.elements.shell: + fem_sec = elem.fem_sec + if fem_sec is None or fem_sec.material is None: + logger.warning(f"Shell element {elem.id} has no section/material; skipping plate conversion") + continue + if fem_sec.type == GeomRepr.SOLID or getattr(fem_sec, "thickness", None) is None: + logger.warning(f"Shell-shaped element {elem.id} has a solid/thickness-less section; not a plate, skipping") + continue + fem_sec.material.parent = p + new_mat = mat_dict.get(fem_sec.material.name, None) + if new_mat is None: + new_mat = p.materials.add(fem_sec.material.copy_to(fem_sec.material.name, parent=p)) + mat_dict[fem_sec.material.name] = new_mat + + t = fem_sec.thickness + nodes = elem.nodes + if len(nodes) == 4: + n0, n1, n2, n3 = nodes[0].p, nodes[1].p, nodes[2].p, nodes[3].p + if is_coplanar(*n0, *n1, *n2, *n3): + entries.append((f"sh{elem.id}", np.array((n0, n1, n2, n3), dtype=float), t, new_mat, False)) + else: + entries.append((f"sh{elem.id}", np.array((n0, n1, n2), dtype=float), t, new_mat, False)) + entries.append((f"sh{elem.id}_1", np.array((n0, n2, n3), dtype=float), t, new_mat, False)) + else: + # The scalar path binds the tri branch to the raw (unconsolidated) + # section material and wraps it in try/except — both preserved. + entries.append((f"sh{elem.id}", np.array([n.p for n in nodes], dtype=float), t, fem_sec.material, True)) + + # Vectorized geometry per polygon arity, results realigned to entry order. + polys: list = [None] * len(entries) + by_k: dict[int, list[int]] = {} + for i, e in enumerate(entries): + by_k.setdefault(e[1].shape[0], []).append(i) + for _k, idxs in by_k.items(): + batch = CurvePoly2d.from_fem_shells_batch(np.stack([entries[i][1] for i in idxs]), parent=p) + for i, poly in zip(idxs, batch): + polys[i] = poly + + plates = [] + for (name, pts, t, mat, catch_errors), poly in zip(entries, polys): + try: + if poly is None: + pl = Plate.from_fem_shell(name, [q for q in pts], t, mat=mat, parent=p) + else: + pl = Plate(name, poly, t, mat=mat, parent=p) + except BaseException as e: + if not catch_errors: + raise + logger.error(f"Unable to convert {name} to plate due to {e}") + continue + plates.append(pl) + + return Plates(plates, parent=p) def convert_part_elem_bm_to_beams(p: Part) -> Beams: diff --git a/src/ada/geom/booleans.py b/src/ada/geom/booleans.py index 7699b376c..21134c8bb 100644 --- a/src/ada/geom/booleans.py +++ b/src/ada/geom/booleans.py @@ -19,14 +19,14 @@ def from_str(cls, value) -> BoolOpEnum: return enum_map.get(value.lower()) -@dataclass +@dataclass(slots=True) class BooleanResult: first_operand: Any second_operand: Any operator: BoolOpEnum -@dataclass +@dataclass(slots=True) class BooleanOperation: second_operand: Geometry operator: BoolOpEnum diff --git a/src/ada/geom/curve_discretize.py b/src/ada/geom/curve_discretize.py index d51ab7276..40204961f 100644 --- a/src/ada/geom/curve_discretize.py +++ b/src/ada/geom/curve_discretize.py @@ -126,4 +126,17 @@ def discretize_curve(curve, deflection: float = 0.1, max_angle: float = 0.35) -> return None _append(out, seg_pts) return out + if type(curve) is cu.TrimmedCurve and isinstance(curve.basis_curve, cu.Line): + # Line basis: point trims are the endpoints; parameter trims evaluate on + # P(t) = pnt + t*dir (the reader keeps the vector magnitude in ``dir``). + b = curve.basis_curve + + def _pt(t): + if not isinstance(t, (int, float)): + return tuple(np.asarray(t, float)) + p = np.asarray(b.pnt, float) + d = np.asarray(b.dir, float) + return tuple(float(v) for v in p + float(t) * d) + + return [_pt(curve.trim1), _pt(curve.trim2)] return None diff --git a/src/ada/geom/curves.py b/src/ada/geom/curves.py index 9a1cb5ebb..8ad4942f4 100644 --- a/src/ada/geom/curves.py +++ b/src/ada/geom/curves.py @@ -30,8 +30,10 @@ "TrimmedCurve", "CompositeCurve", "Clothoid", + "CosineSpiral", "CurveSegment", "GradientCurve", + "SegmentedReferenceCurve", "PCurve", "PointOnCurve", "OffsetCurve3D", @@ -39,7 +41,7 @@ ] -@dataclass +@dataclass(slots=True) class Line: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcLine.htm @@ -56,7 +58,7 @@ def __post_init__(self): self.dir = Direction(*self.dir) -@dataclass +@dataclass(slots=True) class ArcLine: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcArcIndex.htm @@ -87,12 +89,12 @@ def __iter__(self): return iter((self.start, self.midpoint, self.end)) -@dataclass +@dataclass(slots=True) class PolyLine: points: list[Point] -@dataclass +@dataclass(slots=True) class TrimmedCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTrimmedCurve.htm) @@ -111,7 +113,7 @@ class TrimmedCurve: master_representation: str = "PARAMETER" -@dataclass +@dataclass(slots=True) class CompositeCurveSegment: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCompositeCurveSegment.htm) @@ -124,7 +126,7 @@ class CompositeCurveSegment: transition: str = "CONTINUOUS" -@dataclass +@dataclass(slots=True) class CompositeCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCompositeCurve.htm) @@ -137,7 +139,7 @@ class CompositeCurve: self_intersect: bool = False -@dataclass +@dataclass(slots=True) class Clothoid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClothoid.htm) @@ -153,26 +155,50 @@ class Clothoid: clothoid_constant: float -@dataclass +@dataclass(slots=True) +class CosineSpiral: + """ + IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcCosineSpiral.htm) + + A transition spiral whose curvature varies as a cosine of arc length. With A1 = + ``cosine_term``, A0 = ``constant_term`` (optional) and L the length over which the cosine + completes half a period (the containing CurveSegment's length), the heading angle is + ``theta(s) = s/A0 + (L/(pi*A1))*sin(pi*s/L)`` and the curvature ``kappa(s) = 1/A0 + + (1/A1)*cos(pi*s/L)``. Position is obtained by integrating ``(cos theta, sin theta)`` (no + closed form) about the 2D placement (``location`` + unit ``ref_direction``). + """ + + location: Iterable # 2D placement origin + ref_direction: Iterable # 2D unit tangent at s=0 + cosine_term: float # A1 (required) + constant_term: float | None = None # A0 (optional) + + +@dataclass(slots=True) class CurveSegment: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCurveSegment.htm) A ``parent_curve`` restricted to the arc-length range [segment_start, segment_start + - segment_length] and positioned in the containing curve by a 2D placement (``location`` + - unit ``ref_direction``). Distinct from CompositeCurveSegment (no SameSense; carries its own + segment_length] and positioned in the containing curve by a placement (``location`` + unit + ``ref_direction``). Distinct from CompositeCurveSegment (no SameSense; carries its own placement + parametric range). segment_length may be negative (parameter decreases). + + ``location``/``ref_direction`` are 2D for the planar (horizontal/vertical) curves, but 3D for + the cant segments of an IfcSegmentedReferenceCurve (IfcAxis2Placement3D) — the extra vertical + component of ``location`` is the superelevation offset baked onto the base curve. Planar + consumers slice ``location[:2]``, so the 3D form is backward-compatible. """ transition: str - location: Iterable # 2D placement origin (segment start position) - ref_direction: Iterable # 2D unit tangent at the segment start + location: Iterable # placement origin (2D planar, 3D for cant segments) + ref_direction: Iterable # unit tangent at the segment start (2D planar, 3D for cant) segment_start: float segment_length: float parent_curve: CURVE_GEOM_TYPES -@dataclass +@dataclass(slots=True) class GradientCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcGradientCurve.htm) @@ -186,7 +212,24 @@ class GradientCurve: self_intersect: bool = False -@dataclass +@dataclass(slots=True) +class SegmentedReferenceCurve: + """ + IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcSegmentedReferenceCurve.htm) + + A 3D curve defined in the linear parameter space of its ``base_curve`` (typically an + IfcGradientCurve giving x,y,z along arc length). Its ``segments`` carry the cant + (superelevation): each maps a parameter range of the base curve to a vertical offset applied + perpendicular to the base curve axis, producing the rail reference curve. The horizontal + (x,y) is that of the base curve unchanged; only the vertical (z) is displaced by the cant. + """ + + base_curve: "GradientCurve" + segments: list["CurveSegment"] + self_intersect: bool = False + + +@dataclass(slots=True) class IndexedPolyCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcIndexedPolyCurve.htm) @@ -257,12 +300,12 @@ def to_points2d(self): return local_points -@dataclass +@dataclass(slots=True) class GeometricCurveSet: elements: list[CURVE_GEOM_TYPES] -@dataclass +@dataclass(slots=True) class Circle: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCircle.htm @@ -273,7 +316,7 @@ class Circle: radius: float -@dataclass +@dataclass(slots=True) class Ellipse: """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEllipse.htm @@ -285,7 +328,7 @@ class Ellipse: semi_axis2: float -@dataclass +@dataclass(slots=True) class Parabola: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_parabola.html @@ -296,7 +339,7 @@ class Parabola: focal_dist: float -@dataclass +@dataclass(slots=True) class PointOnCurve: """STEP AP242 t_point_on_curve — a point located at ``parameter`` on a basis curve.""" @@ -304,7 +347,7 @@ class PointOnCurve: parameter: float -@dataclass +@dataclass(slots=True) class OffsetCurve3D: """STEP AP242 t_offset_curve_3d — a curve offset from a basis curve by ``distance``.""" @@ -314,7 +357,7 @@ class OffsetCurve3D: ref_direction: "Direction | None" = None -@dataclass +@dataclass(slots=True) class Hyperbola: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_hyperbola.html @@ -356,7 +399,7 @@ def from_str(value: str) -> KnotType: return KnotType(value) -@dataclass +@dataclass(slots=True) class BSplineCurveWithKnots: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineCurveWithKnots.htm) @@ -373,7 +416,7 @@ class BSplineCurveWithKnots: knot_spec: KnotType -@dataclass +@dataclass(slots=True) class PCurve: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPcurve.htm) @@ -383,7 +426,7 @@ class PCurve: reference_curve: CURVE_GEOM_TYPES -@dataclass +@dataclass(slots=True) class RationalBSplineCurveWithKnots(BSplineCurveWithKnots): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRationalBSplineCurveWithKnots.htm) @@ -392,7 +435,7 @@ class RationalBSplineCurveWithKnots(BSplineCurveWithKnots): weights_data: list[float] -@dataclass +@dataclass(slots=True) class Edge: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdge.htm) @@ -430,7 +473,7 @@ def __iter__(self): return iter((self.start, self.end)) -@dataclass +@dataclass(slots=True) class Pcurve2dBSpline: """A 2D B-spline curve in the parameter space (UV) of a surface — the p-curve attached to a coedge in ACIS / STEP / IFC. Carrying the @@ -450,7 +493,7 @@ class Pcurve2dBSpline: closed: bool = False -@dataclass +@dataclass(slots=True) class OrientedEdge(Edge): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcOrientedEdge.htm) @@ -479,7 +522,7 @@ class OrientedEdge(Edge): t_end: float | None = None -@dataclass +@dataclass(slots=True) class EdgeCurve(Edge): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdgeCurve.htm) @@ -490,7 +533,7 @@ class EdgeCurve(Edge): same_sense: bool -@dataclass +@dataclass(slots=True) class PolyLoop: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPolyLoop.htm) @@ -499,7 +542,7 @@ class PolyLoop: polygon: list[Point] -@dataclass +@dataclass(slots=True) class EdgeLoop: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcEdgeLoop.htm) @@ -526,4 +569,12 @@ class EdgeLoop: TrimmedCurve, CompositeCurve, Edge, + # Loose curve collection (STEP GEOMETRIC_CURVE_SET wireframe bodies) — a + # curve body like its members, so every curve-body path treats it as one. + GeometricCurveSet, + # NB: the analytic alignment types (Clothoid / CosineSpiral / GradientCurve / + # SegmentedReferenceCurve) are intentionally NOT here — they are intermediate curves consumed + # by the alignment evaluator and never stored as a Shape's geometry (the reader converts them + # to a sampled PolyLine, which IS a curve body). Listing them would misclassify a swept + # solid's GradientCurve directrix as a bare wire body. ) diff --git a/src/ada/geom/placement.py b/src/ada/geom/placement.py index 46b87ac25..b9ea72ab2 100644 --- a/src/ada/geom/placement.py +++ b/src/ada/geom/placement.py @@ -30,7 +30,7 @@ def ZV() -> Direction: # noqa return Direction(0, 0, 1) -@dataclass +@dataclass(slots=True) class Axis2Placement3D: location: Point | Iterable = field(default_factory=O) @@ -52,13 +52,13 @@ def get_pdir(self): return Direction(*np.cross(self.axis, self.ref_direction)) -@dataclass +@dataclass(slots=True) class IfcLocalPlacement: relative_placement: Axis2Placement3D placement_rel_to: IfcLocalPlacement | None = None -@dataclass +@dataclass(slots=True) class Axis1Placement: location: Point axis: Direction diff --git a/src/ada/geom/solids.py b/src/ada/geom/solids.py index 2d0a629d5..76773b557 100644 --- a/src/ada/geom/solids.py +++ b/src/ada/geom/solids.py @@ -17,7 +17,7 @@ ) -@dataclass +@dataclass(slots=True) class ExtrudedAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcExtrudedAreaSolid.htm) @@ -30,7 +30,7 @@ class ExtrudedAreaSolid: extruded_direction: Direction -@dataclass +@dataclass(slots=True) class ExtrudedAreaSolidTapered(ExtrudedAreaSolid): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcExtrudedAreaSolidTapered.htm) @@ -39,7 +39,7 @@ class ExtrudedAreaSolidTapered(ExtrudedAreaSolid): end_swept_area: ProfileDef -@dataclass +@dataclass(slots=True) class RevolvedAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRevolvedAreaSolid.htm) @@ -52,7 +52,7 @@ class RevolvedAreaSolid: angle: float -@dataclass +@dataclass(slots=True) class FixedReferenceSweptAreaSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcFixedReferenceSweptAreaSolid.htm) @@ -69,7 +69,7 @@ class FixedReferenceSweptAreaSolid: fixed_reference: Direction = field(default_factory=lambda: Direction(0.0, 0.0, 1.0)) -@dataclass +@dataclass(slots=True) class SweptDiskSolid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSweptDiskSolid.htm) @@ -88,7 +88,7 @@ class SweptDiskSolid: fillet_radius: float | None = None -@dataclass +@dataclass(slots=True) class Box: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBlock.htm) @@ -118,7 +118,7 @@ def from_2points(p1: Point, p2: Point) -> Box: return Box.from_xyz_and_dims(x, y, z, x_length, y_length, z_length) -@dataclass +@dataclass(slots=True) class RectangularPyramid: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRectangularPyramid.htm) @@ -131,7 +131,7 @@ class RectangularPyramid: z_length: float -@dataclass +@dataclass(slots=True) class Cone: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRightCircularCone.htm) @@ -152,7 +152,7 @@ def from_2points(p1: Point, p2: Point, r: float) -> Cone: return Cone(axis3d, r, height) -@dataclass +@dataclass(slots=True) class Cylinder: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcRightCircularCylinder.htm) @@ -173,7 +173,7 @@ def from_2points(p1: Point, p2: Point, r: float) -> Cylinder: return Cylinder(axis3d, r, height) -@dataclass +@dataclass(slots=True) class Sphere: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3_0_0/lexical/IfcSphere.htm) @@ -184,7 +184,7 @@ class Sphere: radius: float -@dataclass +@dataclass(slots=True) class Torus: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_torus.html @@ -196,7 +196,7 @@ class Torus: minor_radius: float -@dataclass +@dataclass(slots=True) class AdvancedBrep: """ IFC4x3 (https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcAdvancedBrep.htm) @@ -205,7 +205,7 @@ class AdvancedBrep: outer: ConnectedFaceSet -@dataclass +@dataclass(slots=True) class FacetedBrep: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFacetedBrep.htm) diff --git a/src/ada/geom/surfaces.py b/src/ada/geom/surfaces.py index 3f404231e..22d869372 100644 --- a/src/ada/geom/surfaces.py +++ b/src/ada/geom/surfaces.py @@ -12,12 +12,12 @@ # STEP AP242 and IFC 4x3 -@dataclass +@dataclass(slots=True) class Plane: position: Axis2Placement3D -@dataclass +@dataclass(slots=True) class CylindricalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCylindricalSurface.htm) @@ -28,7 +28,7 @@ class CylindricalSurface: radius: float -@dataclass +@dataclass(slots=True) class ConicalSurface: """ STEP AP242 (https://www.steptools.com/stds/stp_aim/html/t_conical_surface.html) @@ -46,7 +46,7 @@ class ConicalSurface: semi_angle: float # Cone half-angle in radians -@dataclass +@dataclass(slots=True) class SphericalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSphericalSurface.htm) @@ -57,7 +57,7 @@ class SphericalSurface: radius: float -@dataclass +@dataclass(slots=True) class ToroidalSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcToroidalSurface.htm) @@ -83,12 +83,12 @@ def from_str(profile_type: str) -> "ProfileType": raise ValueError(f"Invalid profile type {profile_type}") -@dataclass +@dataclass(slots=True) class ProfileDef: profile_type: ProfileType -@dataclass +@dataclass(slots=True) class ArbitraryProfileDef(ProfileDef): """ IFC4x3 https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcArbitraryProfileDefWithVoids.htm @@ -99,7 +99,7 @@ class ArbitraryProfileDef(ProfileDef): profile_name: str = None -@dataclass +@dataclass(slots=True) class FaceBound: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceBound.htm) @@ -109,7 +109,7 @@ class FaceBound: orientation: bool -@dataclass +@dataclass(slots=True) class Face: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFace.htm) @@ -118,7 +118,7 @@ class Face: bounds: list[FaceBound] -@dataclass +@dataclass(slots=True) class FaceSurface(Face): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceSurface.htm) @@ -129,7 +129,7 @@ class FaceSurface(Face): same_sense: bool = True -@dataclass +@dataclass(slots=True) class ConnectedFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcConnectedFaceSet.htm) @@ -139,7 +139,7 @@ class ConnectedFaceSet: cfs_faces: list["Face | FaceSurface"] -@dataclass +@dataclass(slots=True) class FaceBasedSurfaceModel: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcFaceBasedSurfaceModel.htm) @@ -148,7 +148,7 @@ class FaceBasedSurfaceModel: fbsm_faces: list[ConnectedFaceSet] -@dataclass +@dataclass(slots=True) class CurveBoundedPlane: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCurveBoundedPlane.htm) @@ -159,13 +159,13 @@ class CurveBoundedPlane: inner_boundaries: list[geo_cu.CURVE_GEOM_TYPES] = field(default_factory=list) -@dataclass +@dataclass(slots=True) class HalfSpaceSolid: base_surface: Plane agreement_flag: bool = True -@dataclass +@dataclass(slots=True) class SurfaceOfLinearExtrusion: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSurfaceOfLinearExtrusion.htm) @@ -177,7 +177,7 @@ class SurfaceOfLinearExtrusion: depth: float -@dataclass +@dataclass(slots=True) class SurfaceOfRevolution: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcSurfaceOfRevolution.htm) @@ -193,7 +193,7 @@ class SurfaceOfRevolution: position: Axis2Placement3D = None -@dataclass +@dataclass(slots=True) class RectangularTrimmedSurface: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_rectangular_trimmed_surface.html @@ -209,7 +209,7 @@ class RectangularTrimmedSurface: vsense: bool = True -@dataclass +@dataclass(slots=True) class OffsetSurface: """STEP AP242 https://www.steptools.com/stds/stp_aim/html/t_offset_surface.html @@ -221,7 +221,7 @@ class OffsetSurface: self_intersect: bool = False -@dataclass +@dataclass(slots=True) class PointOnSurface: """STEP AP242 t_point_on_surface — a point at (u, v) on a basis surface.""" @@ -230,7 +230,7 @@ class PointOnSurface: v: float -@dataclass +@dataclass(slots=True) class RectangularCompositeSurface: """STEP AP242 t_rectangular_composite_surface — a grid of surface patches. @@ -240,7 +240,7 @@ class RectangularCompositeSurface: segments: list -@dataclass +@dataclass(slots=True) class IShapeProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcIShapeProfileDef.htm) @@ -255,7 +255,7 @@ class IShapeProfileDef(ProfileDef): flange_slope: float -@dataclass +@dataclass(slots=True) class TShapeProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTShapeProfileDef.htm) @@ -272,7 +272,7 @@ class TShapeProfileDef(ProfileDef): flange_slope: float -@dataclass +@dataclass(slots=True) class CircleProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcCircleProfileDef.htm) @@ -281,7 +281,7 @@ class CircleProfileDef(ProfileDef): radius: float -@dataclass +@dataclass(slots=True) class TriangulatedFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcTriangulatedFaceSet.htm) @@ -292,7 +292,7 @@ class TriangulatedFaceSet: indices: list[int] -@dataclass +@dataclass(slots=True) class PolygonalFaceSet: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcPolygonalFaceSet.htm) @@ -307,7 +307,7 @@ class PolygonalFaceSet: closed: bool = True -@dataclass +@dataclass(slots=True) class RectangleProfileDef(ProfileDef): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRectangleProfileDef.htm) @@ -335,7 +335,7 @@ def from_str(value: str) -> BSplineSurfaceForm: return BSplineSurfaceForm(value) -@dataclass +@dataclass(slots=True) class BSplineSurface: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineSurface.htm) @@ -350,7 +350,7 @@ class BSplineSurface: self_intersect: bool -@dataclass +@dataclass(slots=True) class BSplineSurfaceWithKnots(BSplineSurface): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcBSplineSurfaceWithKnots.htm) @@ -385,7 +385,7 @@ def to_json(self): } -@dataclass +@dataclass(slots=True) class RationalBSplineSurfaceWithKnots(BSplineSurfaceWithKnots): """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcRationalBSplineSurfaceWithKnots.htm) @@ -394,7 +394,7 @@ class RationalBSplineSurfaceWithKnots(BSplineSurfaceWithKnots): weights_data: list[list[float]] -@dataclass +@dataclass(slots=True) class ClosedShell: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClosedShell.htm) @@ -403,7 +403,7 @@ class ClosedShell: cfs_faces: list[Face | FaceSurface | Plane] -@dataclass +@dataclass(slots=True) class OpenShell: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcClosedShell.htm) @@ -412,7 +412,7 @@ class OpenShell: cfs_faces: list[Face | FaceSurface | Plane] -@dataclass +@dataclass(slots=True) class AdvancedFace: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcAdvancedFace.htm) @@ -435,7 +435,7 @@ class AdvancedFace: same_sense: bool = True -@dataclass +@dataclass(slots=True) class WireFilledFace: """A face defined only by its boundary wire — filled by OCC. @@ -451,7 +451,7 @@ class WireFilledFace: bounds: list[FaceBound] -@dataclass +@dataclass(slots=True) class ShellBasedSurfaceModel: """ IFC4x3 (https://standards.buildingsmart.org/IFC/RELEASE/IFC4_3/HTML/lexical/IfcShellBasedSurfaceModel.htm) diff --git a/src/ada/occ/geom/__init__.py b/src/ada/occ/geom/__init__.py index 0f52d3968..e469750d6 100644 --- a/src/ada/occ/geom/__init__.py +++ b/src/ada/occ/geom/__init__.py @@ -62,8 +62,13 @@ def geom_to_occ_geom(geom: Geometry) -> TopoDS_Shape | TopoDS_Solid: occ_geom = geo_su.make_open_shell_from_geom(geometry) elif isinstance(geometry, su.ShellBasedSurfaceModel): occ_geom = geo_su.make_shell_from_shell_based_surface_geom(geometry) + elif isinstance(geometry, su.ConnectedFaceSet): + # The native NGEOM reader's B-rep root form (closed/open not recorded in the buffer). + occ_geom = geo_su.make_shell_from_connected_face_set_geom(geometry) elif isinstance(geometry, su.PolygonalFaceSet): occ_geom = geo_su.make_shell_from_polygonal_face_set_geom(geometry) + elif isinstance(geometry, su.TriangulatedFaceSet): + occ_geom = geo_su.make_shell_from_triangulated_face_set_geom(geometry) # Standalone boolean tree (ada.geom.booleans.BooleanResult): recursively build both operands # (each may itself be a BooleanResult) and apply the OCC boolean. This is the root-geometry form diff --git a/src/ada/occ/geom/curves.py b/src/ada/occ/geom/curves.py index 7fcd5a4bd..8f02a0192 100644 --- a/src/ada/occ/geom/curves.py +++ b/src/ada/occ/geom/curves.py @@ -1,7 +1,7 @@ import math from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire -from OCC.Core.GC import GC_MakeArcOfCircle +from OCC.Core.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse from OCC.Core.Geom import Geom_BSplineCurve from OCC.Core.GeomAPI import GeomAPI_PointsToBSpline, GeomAPI_ProjectPointOnCurve from OCC.Core.gp import gp_Ax2, gp_Circ, gp_Dir, gp_Elips, gp_Pnt @@ -365,21 +365,60 @@ def make_wire_from_ellipse(ellipse: geo_cu.Ellipse) -> TopoDS_Wire: def make_wire_from_trimmed_curve(tc: geo_cu.TrimmedCurve) -> TopoDS_Wire: """Build a bounded wire from a TrimmedCurve. - Supports the two common geometric cases with Cartesian-point trims: a Line basis (straight - segment between the trims) and a Circle basis (arc between the trims). Parameter-only trims - and other bases are not yet built into OCC geometry.""" + Trims may be Cartesian points or parameter values (angles for conic bases, + normalized to radians at read time; line parameters scale the direction + vector, whose IfcVector magnitude the reader preserves). Bases: Line, + Circle, Ellipse.""" + import numpy as np + from ada.geom.points import Point + def _p3(v) -> list[float]: + vals = [float(x) for x in v] + return vals + [0.0] * (3 - len(vals)) + basis = tc.basis_curve - p1, p2 = tc.trim1, tc.trim2 - if not (isinstance(p1, Point) and isinstance(p2, Point)): - raise NotImplementedError("TrimmedCurve OCC build currently requires Cartesian-point trims") + t1, t2 = tc.trim1, tc.trim2 + sense = bool(tc.sense_agreement) if isinstance(basis, geo_cu.Line): - edge = BRepBuilderAPI_MakeEdge(point3d(p1), point3d(p2)).Edge() - elif isinstance(basis, geo_cu.Circle): - circ = gp_Circ(gp_Ax2(gp_Pnt(*basis.position.location), gp_Dir(*basis.position.axis)), float(basis.radius)) - arc = GC_MakeArcOfCircle(circ, point3d(p1), point3d(p2), bool(tc.sense_agreement)).Value() + + def _line_pt(t) -> gp_Pnt: + if isinstance(t, Point): + return point3d(t) + w = np.asarray(_p3(basis.pnt)) + float(t) * np.asarray(_p3(basis.dir)) + return gp_Pnt(*[float(v) for v in w]) + + edge = BRepBuilderAPI_MakeEdge(_line_pt(t1), _line_pt(t2)).Edge() + elif isinstance(basis, (geo_cu.Circle, geo_cu.Ellipse)): + pos = basis.position + loc = np.asarray(_p3(pos.location)) + z = np.asarray(_p3(pos.axis) if pos.axis is not None else [0, 0, 1]) + x = np.asarray(_p3(pos.ref_direction) if getattr(pos, "ref_direction", None) is not None else [1, 0, 0]) + z = z / (np.linalg.norm(z) or 1.0) + x = x / (np.linalg.norm(x) or 1.0) + y = np.cross(z, x) + # Parameter trims are measured from the position's x axis — build the + # gp frame with the explicit XDirection (the 2-arg gp_Ax2 picks an + # arbitrary one). + ax2 = gp_Ax2(gp_Pnt(*loc), gp_Dir(*z), gp_Dir(*x)) + if isinstance(basis, geo_cu.Circle): + sa1 = sa2 = float(basis.radius) + conic = gp_Circ(ax2, sa1) + else: + sa1, sa2 = float(basis.semi_axis1), float(basis.semi_axis2) + conic = gp_Elips(ax2, sa1, sa2) + + def _conic_pt(t) -> gp_Pnt: + if isinstance(t, Point): + return point3d(t) + w = loc + sa1 * np.cos(float(t)) * x + sa2 * np.sin(float(t)) * y + return gp_Pnt(*[float(v) for v in w]) + + if isinstance(basis, geo_cu.Circle): + arc = GC_MakeArcOfCircle(conic, _conic_pt(t1), _conic_pt(t2), sense).Value() + else: + arc = GC_MakeArcOfEllipse(conic, _conic_pt(t1), _conic_pt(t2), sense).Value() edge = BRepBuilderAPI_MakeEdge(arc).Edge() else: raise NotImplementedError(f"TrimmedCurve OCC build not implemented for basis {type(basis)}") @@ -523,10 +562,68 @@ def make_wire_from_curve(outer_curve: geo_cu.CURVE_GEOM_TYPES): return make_wire_from_trimmed_curve(outer_curve) elif isinstance(outer_curve, geo_cu.CompositeCurve): return make_wire_from_composite_curve(outer_curve) + elif isinstance(outer_curve, geo_cu.GradientCurve): + return make_wire_from_gradient_curve(outer_curve) + elif isinstance(outer_curve, geo_cu.PolyLine): + return make_wire_from_polyline(outer_curve) + elif isinstance(outer_curve, geo_cu.GeometricCurveSet): + # Loose curve collection (STEP wireframe body): the members are + # independent curves, so build a compound of wires rather than + # forcing them into one connected wire. + from OCC.Core.BRep import BRep_Builder + from OCC.Core.TopoDS import TopoDS_Compound + + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for element in outer_curve.elements: + builder.Add(compound, make_wire_from_curve(element)) + return compound else: raise NotImplementedError(f"Unsupported curve type {type(outer_curve)}") +def make_wire_from_gradient_curve(curve: geo_cu.GradientCurve) -> TopoDS_Wire: + """Alignment directrix (IFC4x3 IfcGradientCurve): clothoid segments have no + analytic OCC curve, so build a polyline wire from the shared alignment + evaluator's sampling — the same discretization the adacpp backend sweeps.""" + import numpy as np + + from ada.cadit.ngeom._alignment_sweep import gradient_curve_points + + pts = gradient_curve_points(curve, n_per=100) + wire_maker = BRepBuilderAPI_MakeWire() + for a, b in zip(pts[:-1], pts[1:]): + if float(np.linalg.norm(b - a)) < 1e-9: + continue + edge = BRepBuilderAPI_MakeEdge( + gp_Pnt(float(a[0]), float(a[1]), float(a[2])), gp_Pnt(float(b[0]), float(b[1]), float(b[2])) + ).Edge() + wire_maker.Add(edge) + return wire_maker.Wire() + + +def make_wire_from_polyline(curve: geo_cu.PolyLine) -> TopoDS_Wire: + """A sampled polyline (e.g. an evaluated alignment reference curve) -> a polyline wire, so it + round-trips through the OCC B-rep exporters (STEP GEOMETRIC_CURVE_SET, IFC).""" + import numpy as np + + pts = [np.asarray(p, dtype=float) for p in curve.points] + wire_maker = BRepBuilderAPI_MakeWire() + added = 0 + for a, b in zip(pts[:-1], pts[1:]): + if float(np.linalg.norm(b - a)) < 1e-9: + continue + edge = BRepBuilderAPI_MakeEdge( + gp_Pnt(float(a[0]), float(a[1]), float(a[2])), gp_Pnt(float(b[0]), float(b[1]), float(b[2])) + ).Edge() + wire_maker.Add(edge) + added += 1 + if added == 0: + raise NotImplementedError("PolyLine has no non-degenerate segments to build a wire from") + return wire_maker.Wire() + + def make_wire_from_face_bound(face_bound: geo_su.FaceBound) -> TopoDS_Wire: if isinstance(face_bound.bound, geo_cu.PolyLoop): return make_wire_from_poly_loop(face_bound.bound) diff --git a/src/ada/occ/geom/solids.py b/src/ada/occ/geom/solids.py index e569f7b79..7d1d86f6c 100644 --- a/src/ada/occ/geom/solids.py +++ b/src/ada/occ/geom/solids.py @@ -11,6 +11,7 @@ from OCC.Extend.TopologyUtils import TopologyExplorer import ada.geom.solids as geo_so +from ada.config import logger from ada.geom.direction import Direction from ada.geom.points import Point from ada.occ.geom.curves import make_wire_from_curve @@ -203,7 +204,13 @@ def make_fixed_reference_swept_area_shape_from_geom(frs: geo_so.FixedReferenceSw pipe_builder.Add(profile_wire, True, False) # with contact and correction pipe_builder.Build() - pipe_builder.MakeSolid() + try: + pipe_builder.MakeSolid() + except RuntimeError as e: + # An open/non-closable sweep (e.g. a sampled alignment spine) still + # yields a valid shell — renderable and exportable — so degrade to it + # rather than dropping the body. + logger.warning(f"FixedReferenceSweptAreaSolid: MakeSolid failed ({e}); exporting the swept shell") swept_solid = pipe_builder.Shape() location = frs.position.location.tolist() diff --git a/src/ada/occ/geom/surfaces.py b/src/ada/occ/geom/surfaces.py index 196fdc4ec..4bc221333 100644 --- a/src/ada/occ/geom/surfaces.py +++ b/src/ada/occ/geom/surfaces.py @@ -74,6 +74,45 @@ def make_face_from_poly_loop(poly_loop: PolyLoop) -> TopoDS_Shape: return BRepBuilderAPI_MakeFace(wire).Shape() +def _loop_newell(loop: PolyLoop): + """Newell area-vector of a planar loop — magnitude 2*area, direction the winding normal. + Robust for non-convex polygons.""" + import numpy as np + + pts = np.asarray([list(p)[:3] for p in loop.polygon], float) + n = np.zeros(3) + for i in range(len(pts)): + n += np.cross(pts[i], pts[(i + 1) % len(pts)]) + return n + + +def make_planar_face_from_bounds(bounds) -> TopoDS_Shape: + """Planar (IfcFace / faceted-brep) face from its ``FaceBound`` list: the first loop is the + outer boundary, any further loops are cut as holes. The old faceted-brep path used only + ``bounds[0]`` and dropped inner bounds, so a face with an opening (e.g. a basin's rim, whose + IfcFaceOuterBound carries an IfcFaceBound hole for the mouth) built as a solid cap. + + A hole wire must wind opposite to the outer for MakeFace to subtract it, and sources are + inconsistent about the inner loop's stored direction — so decide per inner loop from its + winding relative to the outer normal rather than trusting a fixed convention.""" + import numpy as np + + outer = bounds[0].bound + if not isinstance(outer, PolyLoop): + raise NotImplementedError(f"Only PolyLoop bounds supported for Face, not {type(outer)}") + onrm = _loop_newell(outer) + mk = BRepBuilderAPI_MakeFace(make_wire_from_poly_loop(outer), True) # True: plane inferred from the wire + for fb in bounds[1:]: + inner = fb.bound + if not isinstance(inner, PolyLoop): + raise NotImplementedError(f"Only PolyLoop inner bounds supported for Face, not {type(inner)}") + inner_wire = make_wire_from_poly_loop(inner) + if float(np.dot(_loop_newell(inner), onrm)) > 0.0: # inner winds like the outer -> reverse to cut a hole + inner_wire.Reverse() + mk.Add(inner_wire) + return mk.Face() + + def make_face_from_indexed_poly_curve_geom(curve: geo_cu.IndexedPolyCurve) -> TopoDS_Shape: wire = make_wire_from_indexed_poly_curve_geom(curve) return BRepBuilderAPI_MakeFace(wire).Shape() @@ -89,19 +128,16 @@ def make_face_from_circle(circle: geo_cu.Circle): def make_shell_from_face_based_surface_geom(surface: FaceBasedSurfaceModel) -> TopoDS_Shape: - occ_face = None - for face in surface.fbsm_faces: - for cfs_face in face.cfs_faces: - if not isinstance(cfs_face.bound, PolyLoop): - raise NotImplementedError("Only PolyLoop bounds are supported") - new_face = make_face_from_poly_loop(cfs_face.bound) - if occ_face is None: - occ_face = new_face - else: - # Fuse the new face with the existing face - occ_face = BRepAlgoAPI_Fuse(new_face, occ_face).Shape() - - return occ_face + """Build an IfcFaceBasedSurfaceModel — a set of IfcConnectedFaceSets — into an OCC compound of + shells, mirroring make_shell_from_shell_based_surface_geom. Each connected face set is built by + the shared face-set builder (which reads each Face's ``bounds`` correctly), so an n-face surface + model renders and exports instead of erroring on the old ``cfs_face.bound`` assumption.""" + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for cfs in surface.fbsm_faces: + builder.Add(compound, make_shell_from_connected_face_set_geom(cfs)) + return compound def make_shell_from_curve_bounded_plane_geom(surface: geo_su.CurveBoundedPlane) -> TopoDS_Shape: @@ -1903,10 +1939,9 @@ def _add_cfs_faces_to_shell(builder: BRep_Builder, occ_shell: TopoDS_Shell, cfs_ elif type(cfs_face) is geo_su.Face: n_faces += 1 try: - outer = cfs_face.bounds[0].bound - if not isinstance(outer, PolyLoop): - raise NotImplementedError(f"Only PolyLoop bounds supported for Face, not {type(outer)}") - face = make_face_from_poly_loop(outer) + # bounds[0] is the outer loop; any further bounds are holes (openings) + # in this planar face — build them so an IfcFace with a hole doesn't cap. + face = make_planar_face_from_bounds(cfs_face.bounds) builder.UpdateFace(face, 1e-6) builder.Add(occ_shell, face) except Exception as ex: @@ -1977,6 +2012,24 @@ def make_open_shell_from_geom(shell: geo_su.OpenShell) -> TopoDS_Shell: return occ_shell +def make_shell_from_connected_face_set_geom(cfs: geo_su.ConnectedFaceSet) -> TopoDS_Shell: + """Bare CONNECTED_FACE_SET — the native NGEOM reader's B-rep root form (the buffer + does not record whether the source shell was closed). Build the faces like the + closed/open shells above and let OCC determine closedness, so a manifold solid + B-rep read natively behaves like the Python reader's ClosedShell downstream.""" + builder = BRep_Builder() + occ_shell = TopoDS_Shell() + builder.MakeShell(occ_shell) + _add_cfs_faces_to_shell(builder, occ_shell, cfs.cfs_faces) + try: + from OCC.Core.BRep import BRep_Tool + + occ_shell.Closed(BRep_Tool.IsClosed(occ_shell)) + except Exception: # noqa: BLE001 - closedness flag is an optimisation, not correctness + pass + return occ_shell + + def make_shell_from_polygonal_face_set_geom(pfs: geo_su.PolygonalFaceSet) -> TopoDS_Shape: """Build an IfcPolygonalFaceSet — a shared point list plus n-gon faces — into a sewn OCC shell. Each face is a planar polygon wire (1-based indices into the point list); @@ -2007,6 +2060,38 @@ def make_shell_from_polygonal_face_set_geom(pfs: geo_su.PolygonalFaceSet) -> Top return sewing.SewedShape() +def make_shell_from_triangulated_face_set_geom(tfs: geo_su.TriangulatedFaceSet) -> TopoDS_Shape: + """Build an IfcTriangulatedFaceSet — a shared point list plus flat 1-based index + triples — into a sewn OCC shell, same treatment as the polygonal face set. Needed + for B-rep exports (STEP/IFC) of mesh-native bodies; tessellation itself takes the + direct mesh path and never comes here.""" + coords = tfs.coordinates + idx = [int(i) for i in tfs.indices] + sewing = BRepBuilderAPI_Sewing(1e-6) + n_faces = 0 + for k in range(0, len(idx), 3): + tri = idx[k : k + 3] + poly = BRepBuilderAPI_MakePolygon() + for i in tri: + poly.Add(point3d(coords[i - 1])) + poly.Close() + if not poly.IsDone(): + logger.warning("TriangulatedFaceSet: skipping triangle %s (could not build wire)", tri) + continue + face_maker = BRepBuilderAPI_MakeFace(poly.Wire(), True) + if not face_maker.IsDone(): + logger.warning("TriangulatedFaceSet: skipping degenerate triangle %s", tri) + continue + sewing.Add(face_maker.Face()) + n_faces += 1 + + if n_faces == 0: + raise UnableToCreateTesselationFromSolidOCCGeom("TriangulatedFaceSet produced no usable faces") + + sewing.Perform() + return sewing.SewedShape() + + def make_shell_from_shell_based_surface_geom(sbsm: geo_su.ShellBasedSurfaceModel) -> TopoDS_Shape: """Build an IfcShellBasedSurfaceModel — a set of open/closed shells — into a single OCC compound of shells, so a multi-shell wall/cladding surface renders and exports.""" @@ -2029,7 +2114,13 @@ def make_face_from_curve(outer_curve: geo_cu.CURVE_GEOM_TYPES): elif isinstance(outer_curve, geo_cu.Circle): return make_face_from_circle(outer_curve) else: - raise NotImplementedError("Only IndexedPolyCurve is implemented") + # Composite / trimmed / ellipse outlines (e.g. IFC profile curves + # stitched from IfcTrimmedCurve segments) — build the wire through + # the generic dispatcher and face it planar. + from ada.occ.geom.curves import make_wire_from_curve + + wire = make_wire_from_curve(outer_curve) + return BRepBuilderAPI_MakeFace(wire, True).Shape() def make_profile_from_geom(area: geo_su.ProfileDef) -> TopoDS_Shape | TopoDS_Face: diff --git a/src/ada/occ/store.py b/src/ada/occ/store.py index a2d38e4d9..59d8825b9 100644 --- a/src/ada/occ/store.py +++ b/src/ada/occ/store.py @@ -99,9 +99,9 @@ def safe_geom(obj_, name_ref=None): _g = getattr(obj_, "geom", None) if _g is not None and isinstance(getattr(_g, "geometry", None), CURVE_GEOM_TUPLE): try: - from ada.occ.geom.curves import make_wire_from_curve + from ada.cad import active_backend - occ_geom = make_wire_from_curve(_g.geometry) + occ_geom = active_backend().build(_g) except (NotImplementedError, ImportError) as e: logger.warning(f"Skipping {getattr(obj_, 'name', obj_)!r} in STEP export: {e}") return None diff --git a/src/ada/occ/tessellating.py b/src/ada/occ/tessellating.py index 4f9f483ba..1b935c59b 100644 --- a/src/ada/occ/tessellating.py +++ b/src/ada/occ/tessellating.py @@ -44,6 +44,94 @@ def _is_topods_shape(shape) -> bool: return isinstance(shape, TopoDS_Shape) +# Per-conversion tally of silent NGEOM(libtess2/adacpp-*)->OCC tessellation fallbacks. Incremented +# at the single choke point (_log_tess_fallback), which is only reached when a stream pipeline was +# actually selected — so every hit is a genuine "the OCC-free path fell back to OCC" event. The +# convert subprocess reads + resets this at teardown and emits it to the audit (convert_meta), so a +# conversion that quietly completed on OCC instead of libtess2 is flagged rather than invisible. +from collections import Counter as _Counter + +TESS_FALLBACK_STATS: dict = {"count": 0, "reasons": _Counter(), "geoms": _Counter()} + + +def _record_tess_fallback(reason: str, geom_type: str) -> None: + TESS_FALLBACK_STATS["count"] += 1 + # Keep the reason label short + bounded (the strings are fixed call-site literals). + TESS_FALLBACK_STATS["reasons"][reason.split(":")[0][:40]] += 1 + TESS_FALLBACK_STATS["geoms"][geom_type] += 1 + + +def consume_tess_fallback_stats() -> dict: + """Return the accumulated fallback tally and reset it (per-conversion scope).""" + s = { + "count": int(TESS_FALLBACK_STATS["count"]), + "reasons": dict(TESS_FALLBACK_STATS["reasons"]), + "geoms": dict(TESS_FALLBACK_STATS["geoms"]), + } + TESS_FALLBACK_STATS["count"] = 0 + TESS_FALLBACK_STATS["reasons"].clear() + TESS_FALLBACK_STATS["geoms"].clear() + return s + + +# Per-conversion tally of "crows-nest" spike triangles across every tessellated mesh. Fed by the +# scene builders as they collect meshes (raw triangles, so no meshopt decode) and read out at +# teardown into convert_meta for a per-cell audit flag. A spike is a thin (needle: aspect = +# emax^2 / 2*area large) triangle that touches an OUTLIER vertex — one whose distance from the mesh's +# robust (median) centroid exceeds _SPIKE_OUTLIER_K × the median vertex distance, i.e. a vertex shot +# out past the body. The outlier test is what separates a real spike from benign geometry that a +# raw aspect/reach test over-flags: a deep thin extrusion's side slivers, or a coarse curved surface, +# are thin and reach across the bbox but their vertices sit ON the body, not outside it. Mirrors the +# frontend meshStats "maxSpike" metric that drives the "distorted" gallery walk. +_DISTORTION_ASPECT = 8.0 # emax^2/(2*area) above this = thin/needle (equilateral ~= 3.5) +_SPIKE_OUTLIER_K = 4.0 # vertex dist > this × median vertex dist from the centroid = an outlier +_DISTORTION_SAMPLE_CAP = 300_000 # sample huge meshes so the scan stays sub-ms; count is scaled back +MESH_DISTORTION_STATS: dict = {"n_tris": 0, "distorted": 0} + + +def accumulate_mesh_distortion(positions, indices) -> None: + """Tally crows-nest spike triangles in one mesh (best-effort, vectorized, sampled).""" + try: + v = np.asarray(positions, dtype=float).reshape(-1, 3) + idx = np.asarray(indices, dtype=np.int64).reshape(-1, 3) + except Exception: # noqa: BLE001 - a malformed buffer must never fail a conversion + return + n = len(idx) + if n == 0 or len(v) == 0: + return + # Outlier vertices: distance from the median-per-axis centroid > K × the median distance. + centroid = np.median(v, axis=0) + vdist = np.linalg.norm(v - centroid, axis=1) + med = float(np.median(vdist)) + is_outlier = (vdist > _SPIKE_OUTLIER_K * med) if med > 1e-9 else np.zeros(len(v), dtype=bool) + scale = 1.0 + if n > _DISTORTION_SAMPLE_CAP: + step = n // _DISTORTION_SAMPLE_CAP + 1 + idx = idx[::step] + scale = n / max(len(idx), 1) + tv = v[idx] + e0 = tv[:, 1] - tv[:, 0] + area2 = np.linalg.norm(np.cross(e0, tv[:, 2] - tv[:, 0]), axis=1) # 2*area + emax = np.maximum.reduce( + [np.linalg.norm(e0, axis=1), np.linalg.norm(tv[:, 2] - tv[:, 1], axis=1), np.linalg.norm(tv[:, 0] - tv[:, 2], axis=1)] + ) + with np.errstate(divide="ignore", invalid="ignore"): + aspect = np.where(area2 > 1e-20, emax * emax / area2, np.inf) + touches_outlier = is_outlier[idx].any(axis=1) # any of the tri's 3 vertices is an outlier + distorted = int(((aspect > _DISTORTION_ASPECT) & touches_outlier & (emax > 1e-9)).sum()) + MESH_DISTORTION_STATS["n_tris"] += int(n) + MESH_DISTORTION_STATS["distorted"] += int(round(distorted * scale)) + + +def consume_mesh_distortion_stats() -> dict: + """Return the distortion tally and reset it (per-conversion scope).""" + n = int(MESH_DISTORTION_STATS["n_tris"]) + d = int(MESH_DISTORTION_STATS["distorted"]) + MESH_DISTORTION_STATS["n_tris"] = 0 + MESH_DISTORTION_STATS["distorted"] = 0 + return {"n_tris": n, "distorted_tris": d, "distorted_frac": (d / n if n else 0.0)} + + def _mesh_store_area(ms) -> float: """Total triangle area of a tessellated ``MeshStore`` (position soup + indices). Used to gauge whether a curved-plate prism actually covered its @@ -152,6 +240,36 @@ def _vertex_normals(positions: np.ndarray, faces: np.ndarray) -> np.ndarray: return (normals / lengths).reshape(-1).astype("float32") +def _emit_with_geom_transforms(ms: "MeshStore", ada_obj): + """Yield ``ms`` once per world transform on the object's Geometry wrapper, baking each 4x4 + into the vertex positions (and inverse-transpose into the normals, so non-uniform scale renders + correctly). ``Geometry.transforms`` carries per-instance placements the geometry itself can't + hold — e.g. an IfcMappedItem instanced N times, or a mapped item with a non-rigid (scale/shear) + transform that can't be baked into an analytic solid. No transforms → yield ``ms`` unchanged.""" + transforms = getattr(getattr(ada_obj, "geom", None), "transforms", None) + if not transforms: + yield ms + return + base = np.asarray(ms.position, dtype=np.float64).reshape(-1, 3) + base_n = None + if ms.normal is not None and len(ms.normal): + base_n = np.asarray(ms.normal, dtype=np.float64).reshape(-1, 3) + for mat in transforms: + m = np.asarray(mat, dtype=np.float64) + pos = (np.c_[base, np.ones(len(base))] @ m.T)[:, :3].astype(np.float32).reshape(-1) + nrm = None + if base_n is not None: + try: + nm = np.linalg.inv(m[:3, :3]).T # inverse-transpose: correct under non-uniform scale + except np.linalg.LinAlgError: + nm = m[:3, :3] + n = base_n @ nm.T + ln = np.linalg.norm(n, axis=1, keepdims=True) + ln[ln == 0] = 1.0 + nrm = (n / ln).astype(np.float32).reshape(-1) + yield MeshStore(ms.index, ms.matrix, pos, ms.indices, nrm, ms.material, ms.type, ms.node_ref) + + def _thicken_face_mesh(positions: np.ndarray, faces: np.ndarray, thickness: float): """Extrude an open face mesh into a closed thin solid along a single vector — the area-weighted average surface normal × ``thickness`` — matching OCC's @@ -200,7 +318,10 @@ def tessellate_edges(shape: TopoDS_Edge, deflection=0.01) -> LineMesh: if shape.ShapeType() == TopAbs_EDGE: edges = [shape] else: - edges = list(TopologyExplorer(shape).edges()) or [shape] + # A shape with no edges at all (e.g. an empty compound from a + # geometry-less STEP product) has nothing to discretize — passing the + # shape itself would trip discretize_edge's TopoDS_Edge assert. + edges = list(TopologyExplorer(shape).edges()) verts: list = [] indices: list = [] @@ -590,6 +711,20 @@ def tessellate_occ_geom( if fixed is not None: tess_shape = tessellate_shape(fixed, self.quality, self.render_edges, self.parallel) indices = tess_shape.faces + if len(indices) == 0: + # Wire-only body (e.g. a GEOMETRIC_CURVE_SET STEP wireframe read + # back through the OCC fallback reader): there is no face to + # mesh, but the edges are real geometry — downgrade to line + # rendering instead of returning an empty mesh. Guarded so a + # non-OCC handle just keeps the empty result. + try: + edge_tess = tessellate_edges(occ_geom) + except Exception: # noqa: BLE001 + edge_tess = None + if edge_tess is not None and len(edge_tess.positions): + tess_shape = edge_tess + indices = edge_tess.indices + mesh_type = MeshType.LINES mat_id = self.material_store.get(geom_color, None) if mat_id is None: @@ -613,20 +748,30 @@ def _direct_line_meshstore(self, geom: Geometry, node_ref) -> MeshStore | None: ada.geom.curve_discretize sampler — parity-tested against OCC discretize_edge). Also lets line geometry render on wasm (no pythonocc). Returns None for curve kinds with no native sampler (e.g. B-spline), which fall through to the OCC discretization path.""" + import ada.geom.curves as geo_cu from ada.geom.curve_discretize import discretize_curve deflection = float(os.environ.get("ADA_LINE_DEFLECTION", "0.01")) - pts = discretize_curve(geom.geometry, deflection=deflection) - if not pts or len(pts) < 2: + # A GeometricCurveSet is several independent curves in one body — each + # element gets its own polyline (no connector segment between them). + curves = geom.geometry.elements if isinstance(geom.geometry, geo_cu.GeometricCurveSet) else [geom.geometry] + pts: list = [] + idx: list = [] + for c in curves: + c_pts = discretize_curve(c, deflection=deflection) + if not c_pts or len(c_pts) < 2: + return None # unsupported element — fall back to the OCC path + off = len(pts) + pts.extend(c_pts) + # GL_LINES endpoint pairs: (0,1),(1,2),... — connected polyline (the + # glTF store reshapes indices to (n/2, 2) segments). + for i in range(len(c_pts) - 1): + idx.extend((off + i, off + i + 1)) + if len(pts) < 2: return None # Flat xyz buffer (the gltf store does position.reshape(len/3, 3)). position = np.array([c for p in pts for c in (float(p[0]), float(p[1]), float(p[2]))], dtype=np.float32) - # GL_LINES endpoint pairs: (0,1),(1,2),... — connected polyline (the glTF store reshapes - # indices to (n/2, 2) segments). - idx: list = [] - for i in range(len(pts) - 1): - idx.extend((i, i + 1)) mat_id = self.material_store.get(geom.color, None) if mat_id is None: mat_id = len(self.material_store) @@ -635,6 +780,50 @@ def _direct_line_meshstore(self, geom: Geometry, node_ref) -> MeshStore | None: node_ref, None, position, np.array(idx, dtype=np.uint32), None, mat_id, MeshType.LINES, node_ref ) + def _direct_triangulated_meshstore(self, geom: Geometry, node_ref) -> MeshStore | None: + """Face-set geometry that already IS (or trivially becomes) a triangle mesh — emit it + directly instead of round-tripping through a kernel build + re-tessellation: + + * TriangulatedFaceSet (IfcTriangulatedFaceSet) — coords + triangle index list as-is. + * PolygonalFaceSet (IfcPolygonalFaceSet) — shared coords + n-gon faces; fan-triangulate + each face in 3D. This keeps the ORIGINAL vertices (unlike inferring a plane and + projecting, which flattens non-planar faces and wrecks the mesh), and no backend has a + B-rep build for it — so without this it falls back to OCC. + + Returns None for every other kind.""" + import ada.geom.surfaces as geo_su + + g = geom.geometry + normal = None + if isinstance(g, geo_su.TriangulatedFaceSet): + if not g.coordinates or not g.indices: + return None + position = np.asarray(g.coordinates, dtype=np.float32).reshape(-1) + indices = np.asarray(g.indices, dtype=np.uint32) - 1 # IFC indices are 1-based + if g.normals and len(g.normals) == len(g.coordinates): + normal = np.asarray(g.normals, dtype=np.float32).reshape(-1) + elif isinstance(g, geo_su.PolygonalFaceSet): + if not g.coordinates or not g.faces: + return None + position = np.asarray([list(p)[:3] for p in g.coordinates], dtype=np.float32).reshape(-1) + n_coords = len(g.coordinates) + tri: list[int] = [] + for face in g.faces: + f = [i - 1 for i in face if 0 < i <= n_coords] # IFC 1-based -> 0-based + for k in range(1, len(f) - 1): # fan-triangulate the n-gon (keeps 3D vertices) + tri += (f[0], f[k], f[k + 1]) + if not tri: + return None + indices = np.asarray(tri, dtype=np.uint32) + else: + return None + + mat_id = self.material_store.get(geom.color, None) + if mat_id is None: + mat_id = len(self.material_store) + self.material_store[geom.color] = mat_id + return MeshStore(node_ref, None, position, indices, normal, mat_id, MeshType.TRIANGLES, node_ref) + def tessellate_geom( self, geom: Geometry, @@ -656,6 +845,12 @@ def tessellate_geom( if direct is not None: return direct + # Pre-tessellated geometry: pass the triangles straight through. + if mesh_type == MeshType.TRIANGLES: + direct = self._direct_triangulated_meshstore(geom, node_ref) + if direct is not None: + return direct + # NGEOM stream path (opt-in via ADA_STREAM_TESS_PIPELINE=libtess2|occ|cgal|hybrid): # serialize ada.geom + tessellate in one adacpp call instead of the OCC build + # BRepMesh below. Returns None when the env is unset or the active backend has no @@ -712,6 +907,7 @@ def _log_tess_fallback(node_ref, pipeline: str, reason: str, geom: Geometry | No inner = getattr(geom, "geometry", geom) gt = type(getattr(inner, "geometry", inner)).__name__ msg = f"NGEOM pipeline {pipeline!r} fell back to OCC for {node_ref!r} (geom={gt}): {reason}" + _record_tess_fallback(reason, gt) # Strict mode: a fall back to OCC is a hard failure, so a conversion can enforce/measure # 100% stream-kernel (libtess2/adacpp-*) coverage rather than silently completing on OCC. if os.environ.get("ADA_STREAM_TESS_STRICT", "").strip().lower() not in ("", "0", "false", "no", "off"): @@ -728,32 +924,81 @@ def _tessellate_geom_via_stream(self, geom: Geometry, node_ref, force_pipeline: pipeline = os.environ.get("ADA_STREAM_TESS_PIPELINE") or force_pipeline if not pipeline: return None + # A pre-triangulated / polygonal face set is already a mesh — emit it directly (3D + # fan-triangulation, original vertices). libtess2 can't take it and inferring a plane per + # face would flatten non-planar faces; this keeps it OCC-free without that distortion. + direct = self._direct_triangulated_meshstore(geom, node_ref) + if direct is not None: + return direct be = active_backend() if not hasattr(be, "tessellate_stream"): self._log_tess_fallback(node_ref, pipeline, "active backend has no tessellate_stream", geom) return None - gi = geom.geometry.geometry if hasattr(geom.geometry, "geometry") else geom.geometry - defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) - ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + # Pass the Geometry wrapper through: the serializer unwraps it and folds any + # bool_operations into a BOOLEAN_RESULT chain, so IFC clipping cuts and API + # booleans reach the stream kernel instead of being dropped with the wrapper. + from ada.cad.registry import stream_tess_defaults, stream_tess_model_scale + + defl, ang = stream_tess_defaults() try: - bm = be.tessellate_stream([(str(node_ref), gi)], pipeline=pipeline, deflection=defl, angular_deg=ang) + bm = be.tessellate_stream( + [(str(node_ref), geom)], + pipeline=pipeline, + deflection=defl, + angular_deg=ang, + model_scale=stream_tess_model_scale(), + ) except Exception as e: # noqa: BLE001 - fall back to the OCC build path on any stream failure self._log_tess_fallback(node_ref, pipeline, f"tessellate_stream raised {type(e).__name__}: {e}", geom) return None + ms = self._mesh_store_from_batch(bm, node_ref, geom.color) + if ms is None: + self._log_tess_fallback(node_ref, pipeline, "empty mesh (geom type not NGEOM-serializable)", geom) + return ms + + def _tessellate_blob_via_stream(self, blob, node_ref, color) -> MeshStore | None: + """Tessellate a lazy shape's stored NGEOM buffer directly — no hydration, no + re-serialization: the ShapeStore blob goes straight to the C++ kernel.""" + pipeline = os.environ.get("ADA_STREAM_TESS_PIPELINE") + if not pipeline: + return None + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer"): + return None + from ada.cad.registry import stream_tess_defaults, stream_tess_model_scale + + defl, ang = stream_tess_defaults() + try: + bm = be.tessellate_stream_buffer( + blob, pipeline=pipeline, deflection=defl, angular_deg=ang, model_scale=stream_tess_model_scale() + ) + except Exception as e: # noqa: BLE001 - fall back to the hydrate path on any stream failure + self._log_tess_fallback( + node_ref, pipeline, f"tessellate_stream_buffer raised {type(e).__name__}: {e}", None + ) + return None + return self._mesh_store_from_batch(bm, node_ref, color) + + def _mesh_store_from_batch(self, bm, node_ref, color) -> MeshStore | None: pos = getattr(bm, "positions", None) idx = getattr(bm, "indices", None) if pos is None or idx is None or len(idx) == 0: - self._log_tess_fallback(node_ref, pipeline, "empty mesh (geom type not NGEOM-serializable)", geom) return None pos = np.ascontiguousarray(pos, dtype=np.float32) idx = np.ascontiguousarray(idx, dtype=np.uint32) nrm = getattr(bm, "normals", None) nrm = np.ascontiguousarray(nrm, dtype=np.float32) if nrm is not None and len(nrm) else None - mat_id = self.material_store.get(geom.color, None) + mat_id = self.material_store.get(color, None) if mat_id is None: mat_id = len(self.material_store) - self.material_store[geom.color] = mat_id - return MeshStore(node_ref, None, pos, idx, nrm, mat_id, MeshType.TRIANGLES, node_ref) + self.material_store[color] = mat_id + # Curve-only bodies (alignment axes, trimmed/segmented curves) tessellate to LINES; + # the batch carries the glTF primitive mode so they render as lines, not triangles. + try: + mtype = MeshType.from_int(getattr(bm, "mesh_type", 4)) + except ValueError: + mtype = MeshType.TRIANGLES + return MeshStore(node_ref, None, pos, idx, nrm, mat_id, mtype, node_ref) def batch_tessellate( self, @@ -766,16 +1011,35 @@ def batch_tessellate( for obj in objects: if isinstance(obj, BackendGeom): + from ada.api.shapes import ShapeProxy + ada_obj = obj geom_repr = render_override.get(obj.guid, GeomRepr.SOLID) # A Shape carrying a bare curve geometry (sectionless SAT wire body, open - # wireframe) has no solid/shell — render it as glTF line geometry. + # wireframe — incl. one round-tripped through IFC into the lazy store) has + # no solid/shell — render it as glTF line geometry. Lazy proxies answer + # from their store record instead of hydrating the whole tree. if geom_repr == GeomRepr.SOLID: - _g = getattr(obj, "geom", None) - if _g is not None and isinstance(getattr(_g, "geometry", None), _CURVE_GEOM_TUPLE): - geom_repr = GeomRepr.LINE + if isinstance(obj, ShapeProxy): + if obj.is_bare_curve(): + geom_repr = GeomRepr.LINE + else: + _g = getattr(obj, "geom", None) + if _g is not None and isinstance(getattr(_g, "geometry", None), _CURVE_GEOM_TUPLE): + geom_repr = GeomRepr.LINE node_ref = graph_store.hash_map.get(obj.guid) if graph_store is not None else getattr(obj, "guid", None) + # Lazy-blob fast path: a ShapeProxy backed by an NGEOM buffer tessellates + # straight from the stored blob when the stream kernel is selected — no + # ada.geom hydration and no re-serialization for the whole model. + if geom_repr == GeomRepr.SOLID and isinstance(obj, ShapeProxy): + blob = obj.ngeom_blob() + if blob is not None: + ms_blob = self._tessellate_blob_via_stream(blob, node_ref, obj.color) + if ms_blob is not None: + yield ms_blob + continue + # PlateCurved: prism-extrude the BSpline face by # thickness so the GLB ships a solid (matching what # a flat Plate produces) rather than a thin shell. @@ -1090,6 +1354,19 @@ def batch_tessellate( logger.error(e) except UnableToCreateCurveOCCGeom as e: logger.error(e) + except TessellationFallbackError: + raise # strict mode: a kernel fallback must fail the conversion, not skip a shape + except Exception as e: # noqa: BLE001 + # A single unbuildable shape (e.g. a B-rep face whose trim wire + # won't reconstruct) must not sink the whole export — log loudly + # and continue with the rest of the model. tessellate_geom has + # already logged the full context at ERROR before re-raising. + logger.error( + "skipping %s %r after tessellation failure: %s", + type(ada_obj).__name__ if ada_obj is not None else "geometry", + getattr(ada_obj, "name", None), + e, + ) if ms is not None: # Treat an empty MeshStore the same as a thrown # tessellation error: BRepMesh produced 0 triangles @@ -1103,7 +1380,7 @@ def batch_tessellate( pos_n = 0 if pos is None else (len(pos) if hasattr(pos, "__len__") else 0) idx_n = 0 if idx is None else (len(idx) if hasattr(idx, "__len__") else 0) if pos_n > 0 and idx_n > 0: - yield ms + yield from _emit_with_geom_transforms(ms, ada_obj) continue logger.error( "PlateCurved %r: tessellation produced empty mesh (pos=%d idx=%d)", @@ -1204,15 +1481,19 @@ def meshes_to_trimesh( ) -> trimesh.Scene: import trimesh - all_shapes = sorted(shapes_tess_iter, key=lambda x: x.material) + # Group by (material, mesh type): a merged glTF primitive is a single type, and + # concatenate_stores can't mix triangle stores (with normals) and line/point stores + # (without) — a scene carrying both solids and line geometry (e.g. an alignment reference + # curve alongside a swept solid) would otherwise crash the normal concatenation. + all_shapes = sorted(shapes_tess_iter, key=lambda x: (x.material, x.type.value)) # filter out all shapes associated with an animation, base_frame = graph.top_level.name if graph is not None else "root" scene = trimesh.Scene(base_frame=base_frame) - for mat_id, meshes in groupby(all_shapes, lambda x: x.material): + for (mat_id, _mtype), meshes in groupby(all_shapes, lambda x: (x.material, x.type)): if merge_meshes: - merged_store = concatenate_stores(meshes) + merged_store = concatenate_stores(list(meshes)) merged_mesh_to_trimesh_scene( scene, merged_store, self.get_mat_by_id(mat_id), mat_id, graph, apply_transform=apply_transform ) @@ -1247,8 +1528,15 @@ def tessellate_part( graph_store=graph, ) + # Tally distorted (degenerate/sliver) triangles for the per-cell audit flag as meshes + # stream past — raw triangles, before GLB/meshopt encoding. Best-effort, never alters output. + def _scanned(it): + for ms in it: + accumulate_mesh_distortion(getattr(ms, "position", None), getattr(ms, "indices", None)) + yield ms + scene = self.meshes_to_trimesh( - shapes_tess_iter, graph, merge_meshes=params.merge_meshes, apply_transform=params.apply_transform + _scanned(shapes_tess_iter), graph, merge_meshes=params.merge_meshes, apply_transform=params.apply_transform ) return scene diff --git a/src/ada/sections/profiles.py b/src/ada/sections/profiles.py index 49c2b5c64..afc427473 100644 --- a/src/ada/sections/profiles.py +++ b/src/ada/sections/profiles.py @@ -143,6 +143,20 @@ def iprofiles(sec: Section, return_solid) -> SectionProfile: p8 = (-tw / 2, -h / 2 + tfbtn) p9 = (-tw / 2, h / 2 - tftop) p10 = (-wtop / 2, h / 2 - tftop) + # Round the four web/flange junctions (p4,p5,p8,p9) when the section carries a fillet + # radius (IfcIShapeProfileDef.FilletRadius, stored in ``r``). CurvePoly2d reads the 3rd + # tuple element as a per-vertex fillet radius and inserts the arc. A radius that won't + # fit (>= the flange overhang or half the web depth) is skipped — it would self- + # intersect the outline. + r = getattr(sec, "r", None) + overhang = (wtop - tw) / 2 + web_half = h / 2 - max(tftop, tfbtn) + if r and 0 < r < min(overhang, web_half): + + def _fil(pt): + return (pt[0], pt[1], r) + + p4, p5, p8, p9 = _fil(p4), _fil(p5), _fil(p8), _fil(p9) input_curve = [c1, c2, p3, p4, p5, p6, c4, c3, p7, p8, p9, p10] outer_curve = build_joined(input_curve) diff --git a/src/ada/visit/rendering/resources/index.zip b/src/ada/visit/rendering/resources/index.zip index 03821ec74..a12629c2e 100644 Binary files a/src/ada/visit/rendering/resources/index.zip and b/src/ada/visit/rendering/resources/index.zip differ diff --git a/src/ada/visit/scene_handling/scene_from_object.py b/src/ada/visit/scene_handling/scene_from_object.py index 5417815a5..8bf4ab7a6 100644 --- a/src/ada/visit/scene_handling/scene_from_object.py +++ b/src/ada/visit/scene_handling/scene_from_object.py @@ -46,6 +46,13 @@ def scene_from_object(physical_object: BackendGeom, converter: SceneConverter) - mesh_stores = list(bt.batch_tessellate(physical_objects)) + # Tally distorted (degenerate/sliver) triangles for the per-cell audit flag — raw triangles + # here, before any GLB/meshopt encoding. Best-effort; never affects the scene. + from ada.occ.tessellating import accumulate_mesh_distortion + + for _ms in mesh_stores: + accumulate_mesh_distortion(getattr(_ms, "position", None), getattr(_ms, "indices", None)) + mesh_map = [] for mat_id, meshes in groupby(mesh_stores, lambda x: x.material): meshes = list(meshes) diff --git a/src/ada/visit/scene_handling/scene_from_step_stream.py b/src/ada/visit/scene_handling/scene_from_step_stream.py index 0f9fc47d9..ab60efb43 100644 --- a/src/ada/visit/scene_handling/scene_from_step_stream.py +++ b/src/ada/visit/scene_handling/scene_from_step_stream.py @@ -185,9 +185,17 @@ def _tessellate_geom_worker(geom): _pipeline = os.environ.get("ADA_STREAM_TESS_PIPELINE") if _pipeline and hasattr(be, "tessellate_stream"): defl = float(os.environ.get("ADA_STREAM_TESS_DEFLECTION", "2.0")) - ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", "20.0")) + from ada.cad.registry import DEFAULT_STREAM_TESS_ANGULAR_DEG + ang = float(os.environ.get("ADA_STREAM_TESS_ANGULAR", str(DEFAULT_STREAM_TESS_ANGULAR_DEG))) + # Adaptive coarsening: relax the angular ceiling on features small vs the whole model, so a + # large assembly (e.g. the boiler's asm_22) doesn't over-tessellate every small pipe/torus + # into a slivery "crows nest". model_scale (the model bbox diagonal) is estimated once by + # the parent and passed via env; 0 => off (fixed angular), matching native_step_to_glb. + mscale = float(os.environ.get("ADA_STREAM_TESS_MODEL_SCALE", "0") or "0") gi = geom.geometry.geometry if hasattr(geom.geometry, "geometry") else geom.geometry - bm = be.tessellate_stream([(gid or "0", gi)], pipeline=_pipeline, deflection=defl, angular_deg=ang) + bm = be.tessellate_stream( + [(gid or "0", gi)], pipeline=_pipeline, deflection=defl, angular_deg=ang, model_scale=mscale + ) _pos = getattr(bm, "positions", None) _idx = getattr(bm, "indices", None) if _pos is None or _idx is None or len(_idx) == 0: @@ -488,6 +496,26 @@ def _tessellate_stream(source: StepStreamSource, graph, bt, sink) -> dict: if unit_scale != 1.0: logger.info("scene_from_step_stream: scaling length unit to metres (factor %g)", unit_scale) + # Adaptive tessellation coarsening (matches native_step_to_glb): estimate the model bbox diagonal + # once and expose it to the pool workers via env (they inherit it), so the libtess2 path relaxes + # density on features small vs the whole model instead of over-tessellating every small pipe/torus + # into a slivery crows-nest. Only when the libtess2 stream path is active and not already set; + # gated by ADA_STREAM_TESS_ADAPTIVE (default on, "0/false/off" disables). unit_scale-corrected. + import os + + if os.environ.get("ADA_STREAM_TESS_PIPELINE") and "ADA_STREAM_TESS_MODEL_SCALE" not in os.environ: + _adaptive = (os.environ.get("ADA_STREAM_TESS_ADAPTIVE") or "").strip().lower() not in {"0", "false", "no", "off"} + if _adaptive: + try: + from ada.cadit.step.model_scale import estimate_step_model_scale + + _ms = estimate_step_model_scale(source.path) * unit_scale + if _ms > 0.0: + os.environ["ADA_STREAM_TESS_MODEL_SCALE"] = repr(_ms) + logger.info("scene_from_step_stream: adaptive tessellation model_scale=%.1f", _ms) + except Exception as exc: # noqa: BLE001 - estimation is best-effort; fall back to fixed angular + logger.debug("model_scale estimate failed (%s); fixed-angular tessellation", exc) + root = graph.top_level on_progress = source.on_progress diff --git a/src/frontend/src/components/admin/AuditLogTab.tsx b/src/frontend/src/components/admin/AuditLogTab.tsx index 0ab28a582..99566c773 100644 --- a/src/frontend/src/components/admin/AuditLogTab.tsx +++ b/src/frontend/src/components/admin/AuditLogTab.tsx @@ -20,6 +20,8 @@ import { const ACTIONS = ["", "upload", "download", "convert", "view", "render"]; const KINDS = ["", "shared", "project", "user"]; const TARGETS = ["", "glb", "ifc", "xml", "step", "stl", "obj", "sat"]; +// Job states the queue writes (queue.py JOB_STATUS_*) — server-side filter. +const STATUSES = ["", "queued", "running", "done", "error"]; const PROFILE_SETTING_KEY = "profile_conversions"; @@ -205,6 +207,12 @@ const AuditLogTab: React.FC = () => { onChange={(v) => onFilter({target: v || undefined})} placeholder="any target" /> + onFilter({status: v || undefined})} + placeholder="any state" + /> = ({imageTag}) => { ); }; +// adacpp [STEPPROF-JSON] pipeline summaries (captured when "Profile conversions" +// is on): per-pipeline phase wall/RSS breakdown, kernel-exact peak (VmHWM), +// per-solid stats, achieved parallelism / IO pressure and per-thread utilisation +// — the C++ sibling of the Python profile below. +const CppProfilePanel: React.FC<{profiles: import("@/services/viewerApi").CppProfile[]}> = ({profiles}) => ( +
+
C++ pipeline profile
+ {profiles.map((p, i) => { + const wall = p.wall_ms > 0 ? p.wall_ms : 1; + return ( +
+
+
Pipeline
+
{p.label}
+
Wall
+
{formatDuration(p.wall_ms)}
+
Peak RSS
+
{formatBytes(p.peak_rss_mb * 1024 * 1024)} (VmHWM)
+ {p.cpu_s != null && ( + <>
CPU
+
{p.cpu_s.toFixed(1)} s{p.parallelism != null ? ` — ${p.parallelism.toFixed(2)}x cores busy` : ""}
+ )} + {(p.solids ?? 0) > 0 && ( + <>
Solids
+
{p.solids}{(p.tris ?? 0) > 0 ? ` (${p.tris} tris, max ${p.max_tris_solid}/solid)` : ""}
+ )} + {p.disk_read_mb != null && p.disk_read_mb > 0 && ( + <>
Disk read
+
{p.disk_read_mb.toFixed(0)} MB physical{p.majflt != null ? `, ${p.majflt} major faults` : ""}
+ )} +
+ {p.phases.length > 0 && ( + + + + + + + + + + + {p.phases.map((ph) => ( + + + + + + + ))} + +
phasemsRSSshare
{ph.name}{Math.round(ph.ms)}{Math.round(ph.rss_mb)} MB +
+
+
+
+ )} + {p.notes && Object.keys(p.notes).length > 0 && ( +
+ {Object.entries(p.notes).map(([k, v]) => ( + +
{k}
+
{v}
+
+ ))} +
+ )} + {(p.threads?.length ?? 0) > 0 && ( +
+ threads:{" "} + {p.threads!.map((t) => `t${t.tid}=${Math.round(t.busy_ms)}ms/${t.solids}s`).join(" ")} +
+ )} +
+ ); + })} +
+); + const MetricsTab: React.FC<{ entry: AuditEntry; onDownloadProfile: () => void; @@ -812,6 +902,9 @@ const MetricsTab: React.FC<{ {entry.convert_meta && } + {(entry.convert_meta?.cpp_profile?.length ?? 0) > 0 && ( + + )} {entry.worker_image_tag && } 0) { + ms = sum; + } else { + if (!run.started_at) return "—"; + const start = new Date(run.started_at).getTime(); + const end = run.finished_at ? new Date(run.finished_at).getTime() : Date.now(); + ms = Math.max(0, end - start - (run.idle_ms ?? 0)); + } if (ms < 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms < 3600_000) return `${(ms / 60_000).toFixed(0)}m`; return `${(ms / 3600_000).toFixed(1)}h`; @@ -152,12 +169,77 @@ const METRIC_COLOR_BUCKETS: {cls: string}[] = [ {cls: "bg-red-900/60 border-red-600 text-red-100"}, // outlier ]; +// The derived-blob key a cell produced. Mirrors the server's +// derived_key_for convention: _derived/., except a glb +// target of a source that is already a .glb has no derivation. +function cellDerivedKey(file: string, target: string): string { + if (target === "glb" && file.toLowerCase().endsWith(".glb")) return file; + return `_derived/${file}.${target}`; +} + +// Whether a cell's product can be opened in the 3D viewer. The viewer mounts +// GLB directly and converts any target the converter can turn into GLB +// (ifc/step/xml/...); parity has no artifact, and a non-done cell has nothing +// cached to open. Mirrors ConversionRow's canPreview gate. +function cellViewable(job: AuditRunJob | undefined): boolean { + if (!job || !job.key || !job.target_format) return false; + if (!(job.status === "done" || job.status === "ok")) return false; + const t = job.target_format; + if (t === "glb") return true; + if (t === "parity") return false; + return runtime.conversionTargetsFor(t).includes("glb"); +} + +type CellFlag = {key: string; label: string; title: string; cls: string}; + +// Per-source quality flags, aggregated across the row's cells' convert_meta. Mirrors the +// PerformanceTab "streaming" pill idea: a compact badge per detected issue. +// occ_fallback — the NGEOM/libtess2 (OCC-free) tessellation silently fell back to OCC. +// distorted — a converted mesh has heavily distorted (degenerate/sliver) triangles. +function sourceFlags(cells: Map, targets: string[], file: string): CellFlag[] { + let occ = 0; + let distorted = 0; + let dropped = 0; + for (const t of targets) { + const cm = cells.get(`${file}::${t}`)?.convert_meta; + if (!cm) continue; + occ += cm.occ_fallback?.count ?? 0; + distorted += cm.mesh_flags?.distorted_tris ?? 0; + dropped = Math.max(dropped, cm.geom_health?.dropped_faces ?? 0); + } + const flags: CellFlag[] = []; + if (dropped > 0) + flags.push({ + key: "dropped", + label: "dropped faces", + title: `${dropped} face(s) with a trim boundary tessellated to zero triangles — silently dropped geometry (e.g. a swept/extruded surface the kernel couldn't mesh)`, + cls: "bg-red-900/50 border-red-700 text-red-200", + }); + if (occ > 0) + flags.push({ + key: "occ", + label: "occ fallback", + title: `${occ} object(s) fell back from the libtess2 stream kernel to OCC`, + cls: "bg-amber-900/50 border-amber-700 text-amber-200", + }); + if (distorted > 0) + flags.push({ + key: "distorted", + label: "distorted tris", + title: `${distorted} heavily distorted (degenerate/sliver) triangle(s) in a converted mesh`, + cls: "bg-red-900/50 border-red-700 text-red-200", + }); + return flags; +} + const RunGrid: React.FC<{ jobs: AuditRunJob[]; metric: MetricKey; onCellHistory: (file: string, target: string) => void; onCellDetails: (file: string, target: string) => void; -}> = ({jobs, metric, onCellHistory, onCellDetails}) => { + onCellOpen: (file: string, target: string) => void; + onCellRerun: (file: string, target: string) => void; +}> = ({jobs, metric, onCellHistory, onCellDetails, onCellOpen, onCellRerun}) => { const grid = useMemo(() => buildGrid(jobs), [jobs]); // Right-click (desktop) / long-press (touch) context menu for a cell. @@ -301,6 +383,9 @@ const RunGrid: React.FC<{ .{t} ))} + + flags + @@ -331,6 +416,17 @@ const RunGrid: React.FC<{ ); })} + + {sourceFlags(grid.cells, grid.targets, file).map((f) => ( + + {f.label} + + ))} + ))} @@ -346,6 +442,18 @@ const RunGrid: React.FC<{
{menu.file} · .{menu.target}
+ {cellViewable(grid.cells.get(`${menu.file}::${menu.target}`)) && ( + + )} + {menu.target !== "parity" && ( + + )} )} @@ -1004,6 +1124,18 @@ const AuditRunsTab: React.FC = () => { const [selectedRun, setSelectedRun] = useState(null); const [selectedJobs, setSelectedJobs] = useState([]); const [metric, setMetric] = useState("status"); + // Runtime shown in the overview: sum-of-cell-times vs active wall clock. + // Persisted so the operator's choice sticks across visits. + const [runtimeMode, setRuntimeMode] = useState( + () => (localStorage.getItem("auditRuntimeMode") === "wall" ? "wall" : "cells"), + ); + useEffect(() => { localStorage.setItem("auditRuntimeMode", runtimeMode); }, [runtimeMode]); + // Ambient "audit sweep in progress" toast (shown over the viewer) — operators can hide it here. + const toastHidden = useAuditToastStore((s) => s.hidden); + const toggleToast = useAuditToastStore((s) => s.toggle); + // New-run form: collapsible on mobile (always visible on md+). Auto-collapses + // when a run is opened so the run detail owns the small screen. + const [formOpen, setFormOpen] = useState(true); const [listError, setListError] = useState(null); const [detailError, setDetailError] = useState(null); // Cell whose cross-run history modal is open (from the grid context menu). @@ -1050,6 +1182,9 @@ const AuditRunsTab: React.FC = () => { const onSelectRun = useCallback((runId: string) => { setSelectedId(runId); + // Collapse the new-run form on mobile so the selected run's grid gets + // the viewport (no-op visually on md+, where the form is always shown). + setFormOpen(false); void loadDetail(runId); }, [loadDetail]); @@ -1062,7 +1197,21 @@ const AuditRunsTab: React.FC = () => { return (
- + {/* Mobile-only collapse header for the new-run form. On md+ the form + is always shown (this button is hidden), matching desktop where + screen space isn't scarce. */} + +
+ +
{/* History list. Side-by-side w-80 on md+; full-width @@ -1083,6 +1232,45 @@ const AuditRunsTab: React.FC = () => { "flex-1 min-h-0 border-b border-gray-800 overflow-auto " + (showHistory ? "block" : "hidden md:block") }> + {/* Overview toggle: show each run's runtime as the sum of its + cell times or as active wall clock. Both are relevant — + cells = compute cost, wall = time waited. Sticky so it + stays put while the list scrolls. */} +
+
+ Runtime +
+ + +
+
+ +
{listError && (
{listError}
)} @@ -1127,7 +1315,9 @@ const AuditRunsTab: React.FC = () => {
{run.ok + run.failed + run.skipped} / {run.total} - {fmtRunDuration(run)} + + {fmtRunDuration(run, runtimeMode)} +
{run.total > 0 && (
@@ -1253,6 +1443,24 @@ const AuditRunsTab: React.FC = () => { metric={metric} onCellHistory={(file, target) => setHistoryCell({key: file, target})} onCellDetails={(file, target) => setDetailsCell({file, target})} + onCellOpen={(file, target) => { + // Load the cell's cached product into the + // underlying scene, from the RUN's scope + // (may differ from the browsed one). + void view_in_3d(file, cellDerivedKey(file, target), selectedRun.scope); + }} + onCellRerun={(file, target) => { + // Re-run just this cell in place (force rebuild). Reopens + // the run; the poller then streams the fresh result in. + void (async () => { + try { + await viewerApi.adminAuditRunRerunCell(selectedRun.id, file, target); + await loadDetail(selectedRun.id); + } catch (e) { + window.alert(`Rerun failed: ${(e as Error).message}`); + } + })(); + }} />
diff --git a/src/frontend/src/components/admin/CorpusTab.tsx b/src/frontend/src/components/admin/CorpusTab.tsx index b114d6020..08c8fb6e5 100644 --- a/src/frontend/src/components/admin/CorpusTab.tsx +++ b/src/frontend/src/components/admin/CorpusTab.tsx @@ -1,7 +1,15 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {Corpus, FileEntry, viewerApi} from "@/services/viewerApi"; -import {buildFileTree} from "@/utils/storage/fileTree"; -import FileTreeView from "./FileTreeView"; +import { + buildFileTree, + collectFolderPaths, + loadPendingFolders, + previewKeyList, + savePendingFolders, +} from "@/utils/storage/fileTree"; +import FileTreeView, {FileTreeMutations} from "./FileTreeView"; +import FolderPickerModal from "@/components/common/FolderPickerModal"; +import {scopeUrlPart} from "@/state/scopeStore"; // Admin tab — manage proprietary regression corpora (M3 of the audit // panel design in plan/v2/notes_admin_audit_panel.md). @@ -14,6 +22,13 @@ import FileTreeView from "./FileTreeView"; // // The trigger form on the Audit Runs tab picks a corpus by slug from // the same /admin/corpora list this tab maintains. +// +// Tree mode carries the storage panel's organize affordances (via the +// shared FileTreeView mutations): rename / move / delete files and +// folders, drag-and-drop moves, client-side pending folders, and a +// checkbox / shift+arrow multi-select feeding a bulk Move/Delete +// toolbar. Server ops go through the admin endpoints (corpus scopes +// are admin-only on every axis). const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -24,6 +39,24 @@ function fmtBytes(n: number): string { return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } +function dirnameOf(key: string): string { + const i = key.lastIndexOf("/"); + return i >= 0 ? key.slice(0, i) : ""; +} + +function basenameOf(key: string): string { + return key.split("/").pop() ?? key; +} + +function normKey(key: string): string { + return key.replace(/^\/+/, ""); +} + +// Batch size for chunked server-side moves/copies. Every chunk is still +// a Garage-side CopyObject (no file bytes through the browser) — the +// chunking only exists so the progress counter ticks between requests. +const OP_CHUNK = 8; + // Flat-list ⇄ folder-tree representation switch. Storage is flat on the // server; tree mode just groups the keys' "/" segments. Shared by the // corpus file overview and the copy-from-scope modal. @@ -173,10 +206,7 @@ const CopyFromScopeModal: React.FC<{ const me = await viewerApi.me(); setScopes( me.scopes - .map((s) => ({ - name: s.name, - url: s.kind === "user" ? "user:me" : s.kind === "shared" ? "shared" : `project:${s.id}`, - })) + .map((s) => ({name: s.name, url: scopeUrlPart(s)})) .filter((s) => s.url !== dstScope), ); } catch (e) { @@ -384,10 +414,35 @@ const CopyFromScopeModal: React.FC<{ ); }; -const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { +const CorpusFiles: React.FC<{ + corpus: Corpus; + /** Name/description saved — the parent re-fetches the corpora list + * so the sidebar and this header pick up the new values. */ + onMetaUpdated: () => void; +}> = ({corpus, onMetaUpdated}) => { const scope = `corpus:${corpus.slug}`; const [files, setFiles] = useState([]); const [err, setErr] = useState(null); + // Transient success line (e.g. copy-to-personal outcome). + const [note, setNote] = useState(null); + // In-flight batch operation (move / delete / copy) — rendered as a + // spinner status bar under the button row so a drag-drop move of + // many files visibly runs until the listing refreshes. The ref + // mirrors it so callbacks can reject overlapping batches without + // stale-closure issues (concurrent moves would race server-side). + const [busy, setBusy] = useState(null); + const busyRef = useRef(false); + const beginOp = (msg: string): boolean => { + if (busyRef.current) return false; + busyRef.current = true; + setBusy(msg); + return true; + }; + const updateOp = (msg: string) => setBusy(msg); + const endOp = () => { + busyRef.current = false; + setBusy(null); + }; const [uploading, setUploading] = useState(null); const [progress, setProgress] = useState(0); const [copyOpen, setCopyOpen] = useState(false); @@ -421,36 +476,184 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { useEffect(() => { void reload(); }, [reload]); - const onPick = useCallback(async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - if (files.length === 0) return; + // Client-side "pending" empty folders — storage is prefix-based so + // they have no server representation until a file lands in them. + // Persisted per corpus scope; pruned once a real key appears + // underneath (same mechanics as StorageBrowser). + const [pendingFolders, setPendingFolders] = useState( + () => loadPendingFolders("corpus", scope), + ); + useEffect(() => { + savePendingFolders("corpus", scope, pendingFolders); + }, [scope, pendingFolders]); + useEffect(() => { + setPendingFolders((prev) => { + const next = prev.filter( + (p) => !files.some((f) => normKey(f.key).startsWith(p + "/")), + ); + return next.length === prev.length ? prev : next; + }); + }, [files]); + const removePendingFoldersUnder = (path: string) => { + setPendingFolders((prev) => + prev.filter((p) => p !== path && !p.startsWith(path + "/")), + ); + }; + // Rename/move of a pending (empty) folder is pure client state. + const rekeyPendingFolders = (oldPath: string, newPath: string) => { + setPendingFolders((prev) => prev.map((p) => ( + p === oldPath + ? newPath + : p.startsWith(oldPath + "/") + ? newPath + p.slice(oldPath.length) + : p + ))); + }; + const folderHasKeys = useCallback( + (path: string) => files.some((f) => normKey(f.key).startsWith(path + "/")), + [files], + ); + + // Inline name/description editor in the header. The slug is + // immutable (storage prefix + scope URLs hang off it), so only the + // display fields are editable. Seeded from the current corpus row + // each time the editor opens. + const [editingMeta, setEditingMeta] = useState(false); + const [metaName, setMetaName] = useState(""); + const [metaDesc, setMetaDesc] = useState(""); + const [metaBusy, setMetaBusy] = useState(false); + const openMetaEdit = () => { + setMetaName(corpus.name); + setMetaDesc(corpus.description ?? ""); + setEditingMeta(true); + }; + const saveMeta = async () => { + const name = metaName.trim(); + if (!name) { + setErr("name required"); + return; + } + setMetaBusy(true); + try { + await viewerApi.adminCorpusUpdate(corpus.slug, { + name, + description: metaDesc.trim() || null, + }); + setEditingMeta(false); + setErr(null); + onMetaUpdated(); + } catch (e) { + setErr((e as Error).message || "corpus update failed"); + } finally { + setMetaBusy(false); + } + }; + + // Where the tree's "new folder" inline input shows ("" = top level). + const [newFolderAt, setNewFolderAt] = useState(null); + // Destination-folder modal shared by the upload and move flows. + const [picker, setPicker] = useState<{ + title: string; + allowRoot?: boolean; + submitLabel?: string; + onPick: (folder: string) => Promise | void; + } | null>(null); + + // Multi-select (tree mode): checkbox / shift+arrow selection set + // feeding the bulk Move/Delete toolbar. Dragging a selected row + // drags the whole set (FileTreeView handles that). + const [selected, setSelected] = useState>(() => new Set()); + const setSelection = useCallback((keys: string[], select: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (select) keys.forEach((k) => next.add(k)); + else keys.forEach((k) => next.delete(k)); + return next; + }); + }, []); + const clearSelection = () => setSelected(new Set()); + // Drop selection entries whose keys vanished (moved/renamed/deleted). + useEffect(() => { + setSelected((prev) => { + const live = new Set(files.map((f) => f.key)); + const next = new Set(Array.from(prev).filter((k) => live.has(k))); + return next.size === prev.size ? prev : next; + }); + }, [files]); + + const existingFolderPaths = useMemo( + () => Array.from(new Set([ + ...collectFolderPaths(files, (f) => f.key), + ...pendingFolders, + ])).sort((a, b) => a.localeCompare(b)), + [files, pendingFolders], + ); + + // Failed uploads from the last batch — drives the retry dialog. + // Holds the actual File objects so Retry can re-attempt without + // re-picking them from disk. + const [uploadFailures, setUploadFailures] = useState<{ + folder?: string; + failed: Array<{file: File; reason: string}>; + } | null>(null); + + // Upload a batch sequentially into an optional folder prefix. Pin + // autoConvert:false so we don't auto-generate derived blobs for + // corpus uploads — the audit dispatcher does that on demand when + // the sweep fires. Files whose destination key already exists in + // the corpus are skipped, never overwritten (same semantics as the + // copy flows). A failed file doesn't abort the batch — failures + // land in the retry dialog. + const uploadFilesTo = useCallback(async (list: File[], folder?: string) => { + if (list.length === 0) return; setErr(null); - // Reuse the existing per-scope upload path. Pin autoConvert:false so we - // don't auto-generate derived blobs for corpus uploads — the audit - // dispatcher does that on demand when the sweep fires. Sequential; a - // failed file is reported without aborting the batch. + setNote(null); + const existing = new Set(files.map((f) => normKey(f.key))); + const targetKey = (file: File) => (folder ? `${folder}/${file.name}` : file.name); + const skipped = list.filter((f) => existing.has(targetKey(f))); + const toUpload = list.filter((f) => !existing.has(targetKey(f))); const {uploadFile} = await import("@/utils/scene/handlers/upload_source_file"); - const failures: string[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - setUploading(files.length > 1 ? `${file.name} (${i + 1}/${files.length})` : file.name); + const failed: Array<{file: File; reason: string}> = []; + for (let i = 0; i < toUpload.length; i++) { + const file = toUpload[i]; + setUploading(toUpload.length > 1 ? `${file.name} (${i + 1}/${toUpload.length})` : file.name); setProgress(0); try { await uploadFile(file, { autoConvert: false, scope, + folder, onProgress: (loaded, total) => setProgress(total > 0 ? loaded / total : 0), }); } catch (e) { - failures.push(`${file.name}: ${(e as Error).message || "upload failed"}`); + failed.push({file, reason: (e as Error).message || "upload failed"}); } } setUploading(null); setProgress(0); - if (inputRef.current) inputRef.current.value = ""; - if (failures.length) setErr(failures.join("; ")); await reload(); - }, [scope, reload]); + const bits: string[] = []; + const uploaded = toUpload.length - failed.length; + if (uploaded > 0) bits.push(`uploaded ${uploaded}`); + if (skipped.length > 0) bits.push(`skipped ${skipped.length} (already in corpus)`); + if (bits.length > 0) setNote(bits.join(" · ")); + if (failed.length > 0) setUploadFailures({folder, failed}); + }, [files, scope, reload]); + + // Upload button flow: pick the files first, then prompt for the + // destination folder — an existing folder, a new path, or the top + // level (the default). + const onPickUpload = useCallback((e: React.ChangeEvent) => { + const picked = Array.from(e.target.files ?? []); + if (inputRef.current) inputRef.current.value = ""; + if (picked.length === 0) return; + setPicker({ + title: `Upload ${picked.length} file${picked.length === 1 ? "" : "s"} to`, + allowRoot: true, + submitLabel: "Upload", + onPick: (folder) => void uploadFilesTo(picked, folder || undefined), + }); + }, [uploadFilesTo]); const onDelete = useCallback(async (key: string) => { if (!confirm(`Delete ${key} from ${corpus.slug}? This can't be undone.`)) return; @@ -462,24 +665,313 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { } }, [scope, corpus.slug, reload]); + const alertFailures = (failed: Array<{key: string; reason: string}>) => { + if (failed.length > 0) { + window.alert(failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); + } + }; + + const runFolderMove = useCallback(async (folderPath: string, newPath: string) => { + if (newPath === folderPath) return; + const count = files.filter((f) => normKey(f.key).startsWith(folderPath + "/")).length; + if (!beginOp( + `Moving folder "${folderPath}" → "${newPath}" (${count} file${count === 1 ? "" : "s"})…`, + )) return; + try { + const allKeys = files.map((f) => f.key); + const r = await viewerApi.adminRenameOrMoveFolder(scope, folderPath, newPath, allKeys); + alertFailures(r.failed); + removePendingFoldersUnder(folderPath); + await reload(); + } catch (e) { + setErr((e as Error).message || "folder move failed"); + } finally { + endOp(); + } + }, [files, scope, reload]); + + const moveKeys = useCallback(async (keys: string[], destFolder: string) => { + const label = destFolder ? `${destFolder}/` : "root /"; + if (!beginOp(`Moving 0/${keys.length} to ${label}…`)) return; + try { + if (destFolder === "") { + // Move-to-root: the move endpoint requires a non-empty + // folder, so root moves are per-key renames to the + // basename. + let done = 0; + for (const k of keys) { + updateOp(`Moving ${done + 1}/${keys.length} to ${label}…`); + await viewerApi.adminRenameKey(scope, k, basenameOf(k)); + done++; + } + } else { + const failed: Array<{key: string; reason: string}> = []; + for (let i = 0; i < keys.length; i += OP_CHUNK) { + const chunk = keys.slice(i, i + OP_CHUNK); + updateOp(`Moving ${Math.min(i + chunk.length, keys.length)}/${keys.length} to ${label}…`); + const r = await viewerApi.adminMoveKeysToFolder(scope, chunk, destFolder); + failed.push(...r.failed); + } + alertFailures(failed); + } + clearSelection(); + await reload(); + } catch (e) { + setErr((e as Error).message || "move failed"); + } finally { + endOp(); + } + }, [scope, reload]); + + // Folder subtree lands at ``destFolder``/``basename`` ("" = root). + const moveFolderTo = (path: string, destFolder: string) => { + const base = basenameOf(path); + const newPath = destFolder ? `${destFolder}/${base}` : base; + if (!folderHasKeys(path)) { + rekeyPendingFolders(path, newPath); + return; + } + void runFolderMove(path, newPath); + }; + + // Shared by the bulk toolbar and the Delete key: confirm with an + // overview of exactly what goes, then delete sequentially. + const deleteKeysWithConfirm = useCallback(async (keys: string[]) => { + if (keys.length === 0) return; + if (!confirm( + `Delete ${keys.length} file${keys.length === 1 ? "" : "s"} from ${corpus.slug}? ` + + "This can't be undone.\n\n" + + previewKeyList(keys), + )) return; + if (!beginOp(`Deleting 0/${keys.length}…`)) return; + try { + let done = 0; + for (const k of keys) { + updateOp(`Deleting ${done + 1}/${keys.length}…`); + await viewerApi.adminDeleteBlob(scope, k); + done++; + } + setSelected(new Set()); + await reload(); + } catch (e) { + setErr((e as Error).message || "delete failed"); + } finally { + endOp(); + } + }, [scope, corpus.slug, reload]); + + // Server-side copy (Garage CopyObject) corpus → the caller's + // personal scope, preserving keys. Existing keys are skipped, not + // overwritten — same semantics as the copy-into-corpus modal. + const copyToPersonal = useCallback(async (keys: string[]) => { + if (keys.length === 0) return; + if (!beginOp(`Copying 0/${keys.length} to your files…`)) return; + setNote(null); + try { + let copied = 0; + let skipped = 0; + const failed: Array<{key: string; reason: string}> = []; + for (let i = 0; i < keys.length; i += OP_CHUNK) { + const chunk = keys.slice(i, i + OP_CHUNK); + updateOp(`Copying ${Math.min(i + chunk.length, keys.length)}/${keys.length} to your files…`); + const r = await viewerApi.adminCopyKeysFromScope("user:me", scope, chunk); + copied += r.copied.length; + skipped += r.skipped.length; + failed.push(...r.failed); + } + alertFailures(failed); + setNote( + `copied ${copied} to your files` + + (skipped > 0 ? ` · skipped ${skipped} (already there)` : ""), + ); + } catch (e) { + setErr((e as Error).message || "copy to personal scope failed"); + } finally { + endOp(); + } + }, [scope]); + + const mutations: FileTreeMutations = { + renameFile: (key, newName) => { + const dir = dirnameOf(key); + const newKey = dir ? `${dir}/${newName}` : newName; + void (async () => { + try { + await viewerApi.adminRenameKey(scope, key, newKey); + await reload(); + } catch (e) { + setErr((e as Error).message || "rename failed"); + } + })(); + }, + renameFolder: (path, newName) => { + const parent = dirnameOf(path); + const newPath = parent ? `${parent}/${newName}` : newName; + if (!folderHasKeys(path)) { + rekeyPendingFolders(path, newPath); + return; + } + void runFolderMove(path, newPath); + }, + moveKeys: (keys, destFolder) => void moveKeys(keys, destFolder), + moveFolder: moveFolderTo, + deleteFile: (key) => void onDelete(key), + deleteFolder: (path, fileCount) => { + if (fileCount === 0) { + // Pending (empty) folder — pure client state. + removePendingFoldersUnder(path); + return; + } + const prefix = path + "/"; + const targets = files.filter((f) => normKey(f.key).startsWith(prefix)); + if (!confirm( + `Delete folder "${path}" and its ${fileCount} file${fileCount === 1 ? "" : "s"} ` + + `from ${corpus.slug}? This can't be undone.\n\n` + + previewKeyList(targets.map((t) => t.key)), + )) return; + void (async () => { + if (!beginOp(`Deleting 0/${targets.length} from "${path}"…`)) return; + try { + // Sequential: deletes cascade derived blobs server-side + // and parallel calls would race on the storage listing. + let done = 0; + for (const t of targets) { + updateOp(`Deleting ${done + 1}/${targets.length} from "${path}"…`); + await viewerApi.adminDeleteBlob(scope, t.key); + done++; + } + removePendingFoldersUnder(path); + await reload(); + } catch (e) { + setErr((e as Error).message || "folder delete failed"); + } finally { + endOp(); + } + })(); + }, + createFolder: (parent, name) => { + // ``_derived`` is where the converter parks derived blobs — + // a user folder with that name would collide with the cache + // prefix. + if (!parent && name === "_derived") { + window.alert(`"${name}" is a reserved name`); + return; + } + const path = parent ? `${parent}/${name}` : name; + setPendingFolders((prev) => (prev.includes(path) ? prev : [...prev, path])); + }, + requestMoveFile: (key) => setPicker({ + title: `Move "${key}" to folder`, + onPick: (folder) => void moveKeys([key], folder), + }), + requestMoveFolder: (path) => setPicker({ + title: `Move folder "${path}" into`, + onPick: (dest) => moveFolderTo(path, dest), + }), + deleteKeys: (keys) => void deleteKeysWithConfirm(keys), + uploadTo: (folder, list) => void uploadFilesTo(list, folder || undefined), + downloadFile: (key) => void viewerApi.downloadBlob(scope, key, basenameOf(key)), + }; + + const onMoveSelected = () => { + const keys = Array.from(selected); + if (keys.length === 0) return; + setPicker({ + title: `Move ${keys.length} file${keys.length === 1 ? "" : "s"} to folder`, + onPick: (folder) => void moveKeys(keys, folder), + }); + }; + + const showTree = viewMode === "tree" && + (files.length > 0 || pendingFolders.length > 0 || newFolderAt !== null); + return (
-
-
{corpus.slug}
- {corpus.description && ( -
{corpus.description}
- )} -
+ {!editingMeta ? ( +
+
+
+ {corpus.slug} + · {corpus.name} +
+ {corpus.description && ( +
{corpus.description}
+ )} +
+ +
+ ) : ( +
{ + e.preventDefault(); + void saveMeta(); + }} + > +
{corpus.slug}
+ setMetaName(e.target.value)} + placeholder="Name" + autoFocus + className="bg-gray-900 border border-gray-600 rounded-sm px-2 py-1 text-gray-100" + /> + setMetaDesc(e.target.value)} + placeholder="Description (optional)" + onKeyDown={(e) => { + if (e.key === "Escape") setEditingMeta(false); + }} + className="bg-gray-900 border border-gray-600 rounded-sm px-2 py-1 text-gray-100" + /> +
+ + +
+
+ )}
- {files.length > 0 && ( - + + {viewMode === "tree" && ( + )} @@ -511,11 +1003,59 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { onCopied={() => void reload()} /> )} + {busy && ( +
+
+ )} {err && (
{err}
)} + {note && !err && !busy && ( +
{note}
+ )} + {viewMode === "tree" && selected.size > 0 && ( +
+ + {selected.size} selected + + + + + +
+ )}
- {files.length === 0 && !err && ( + {files.length === 0 && !err && pendingFolders.length === 0 && newFolderAt === null && (
No files yet. Upload representative source files (STEP / IFC / RMED / etc.) to drive regression sweeps from the @@ -547,7 +1087,7 @@ const CorpusFiles: React.FC<{corpus: Corpus}> = ({corpus}) => { - + {fmtBytes(f.size)} )} />
)}
+ setPicker(null)} + onPick={(folder) => { + const action = picker?.onPick; + setPicker(null); + if (action) void action(folder); + }} + /> + {/* Failed-uploads overview + retry. Not portaled — like the + copy-from-scope modal it renders inside the admin panel's + stacking context, so z-50 suffices here. Retry re-runs + only the failed files (the skip-existing pre-check makes + a partially-succeeded upload a safe no-op). */} + {uploadFailures && ( +
+
+
+ {uploadFailures.failed.length} upload{uploadFailures.failed.length === 1 ? "" : "s"} failed + {uploadFailures.folder ? ( + → {uploadFailures.folder}/ + ) : null} +
+
+
    + {uploadFailures.failed.map(({file, reason}) => ( +
  • + + {file.name} + + + {reason} + +
  • + ))} +
+
+
+ + +
+
+
+ )}
); }; @@ -711,7 +1317,7 @@ const CorpusTab: React.FC = () => { ← corpora
- + )}
diff --git a/src/frontend/src/components/admin/FileTreeView.tsx b/src/frontend/src/components/admin/FileTreeView.tsx index 4b9373912..689e90726 100644 --- a/src/frontend/src/components/admin/FileTreeView.tsx +++ b/src/frontend/src/components/admin/FileTreeView.tsx @@ -8,14 +8,43 @@ import FileTypeIcon from "../icons/FileTypeIcon"; import FolderClosedIcon from "../icons/FolderClosedIcon"; import FolderOpenIcon from "../icons/FolderOpenIcon"; import ChevronRightIcon from "../icons/ChevronRightIcon"; +import InlineNameInput from "@/components/common/InlineNameInput"; +import {RowKebabMenu, KebabMenuItem} from "@/components/common/RowKebabMenu"; -// Generic, read-mostly folder-tree view shared by the admin Corpus tab's -// file overview and its "Copy from scope" modal. Storage stays flat on the -// server; folders are presentational (a key's "/" segments). The tree shape -// itself is built by the caller via ``buildFileTree`` from -// ``@/utils/storage/fileTree`` — this component only renders + manages -// collapse state, mirroring StorageBrowser's row look (indent per depth, -// chevron + folder glyph, file rows) without its drag/rename/scene machinery. +// Generic folder-tree view shared by the admin Corpus tab's file +// overview, its "Copy from scope" modal, and the utility file picker. +// Storage stays flat on the server; folders are presentational (a +// key's "/" segments). The tree shape itself is built by the caller +// via ``buildFileTree`` from ``@/utils/storage/fileTree`` — this +// component renders + manages collapse state, mirroring +// StorageBrowser's row look (indent per depth, chevron + folder +// glyph, file rows). +// +// Read-only by default. Passing ``mutations`` turns on the organize +// affordances from the storage panel: per-row kebab menus (rename / +// move / delete / new subfolder / upload here), inline rename inputs, +// and drag-and-drop moves between folders (plus a move-to-root strip). +// The component owns the interaction state; the server semantics — +// endpoints, confirm dialogs, list reloads — stay with the caller. + +// Drag payload MIMEs. Deliberately distinct from StorageBrowser's +// ``application/x-adapy-keys`` (which carries a bare key array): the +// payload here embeds the source scope and drops verify it, so a drag +// that strays in from another panel (possibly another scope) is +// ignored instead of mis-moved. Types are readable during dragover, +// the payload only on drop — presence of the type alone distinguishes +// internal drags from OS-file drops. +const TREE_KEYS_MIME = "application/x-adapy-tree-keys"; +const TREE_FOLDER_MIME = "application/x-adapy-tree-folder"; + +function dirnameOf(key: string): string { + const i = key.lastIndexOf("/"); + return i >= 0 ? key.slice(0, i) : ""; +} + +function basenameOf(key: string): string { + return key.split("/").pop() ?? key; +} export interface FileTreeSelection { selected: ReadonlySet; @@ -24,6 +53,39 @@ export interface FileTreeSelection { onSelect: (keys: string[], select: boolean) => void; } +/** Write affordances. All semantics (server calls, confirm dialogs, + * list reloads, pending-folder bookkeeping) live in the caller — the + * tree only runs the interaction (menus, inline inputs, drag & drop) + * and validates names before dispatching. */ +export interface FileTreeMutations { + /** Inline rename committed: new basename for the file (no "/"). */ + renameFile: (key: string, newName: string) => void; + /** Inline rename committed: new single-segment name for the folder. */ + renameFolder: (path: string, newName: string) => void; + /** Drag-drop move of file keys into ``destFolder`` ("" = root). */ + moveKeys: (keys: string[], destFolder: string) => void; + /** Drag-drop move of a whole folder under ``destFolder`` ("" = root). */ + moveFolder: (folderPath: string, destFolder: string) => void; + deleteFile: (key: string) => void; + /** ``fileCount`` = files under the prefix; 0 means a client-side + * pending (empty) folder the caller can drop without a server call. */ + deleteFolder: (path: string, fileCount: number) => void; + /** Bulk delete — the Delete key targets the whole selection when + * one exists. The caller confirms with an overview of the keys. */ + deleteKeys?: (keys: string[]) => void; + /** "New folder" input committed (single-segment name; ``parent`` + * "" = root). The caller materialises it (pending-folder state). */ + createFolder: (parent: string, name: string) => void; + /** Open the caller's move-to-folder picker for one file. */ + requestMoveFile?: (key: string) => void; + /** Open the caller's move-folder-into picker. */ + requestMoveFolder?: (path: string) => void; + /** Upload OS files into a folder ("" = root) — used by the folder + * menu's "Upload here…" and by OS-file drops. Omit to disable both. */ + uploadTo?: (folder: string, files: File[]) => void; + downloadFile?: (key: string) => void; +} + interface FileTreeViewProps { nodes: FileTreeNode[]; /** Stable identity for a file entry — the storage key. */ @@ -39,6 +101,17 @@ interface FileTreeViewProps { isDisabled?: (file: T) => boolean; /** Right-aligned per-file slot for actions / labels (size, delete, …). */ renderFileTail?: (file: T) => React.ReactNode; + /** Enable the organize affordances (see FileTreeMutations). */ + mutations?: FileTreeMutations; + /** Caller-specific kebab items appended between the standard file + * actions and Delete (e.g. the corpus tab's "Copy to my files"). */ + extraFileMenuItems?: (key: string) => KebabMenuItem[]; + /** Where the "new folder" inline input shows: "" = top level, a + * folder path = inside it, null/undefined = hidden. Owned by the + * caller so a header-level button can trigger root creation; the + * folder menu's "New subfolder…" routes through it too. */ + newFolderAt?: string | null; + onNewFolderAtChange?: (v: string | null) => void; } // Every enabled descendant file key under a node — drives folder-level @@ -90,6 +163,10 @@ export function FileTreeView({ selection, isDisabled, renderFileTail, + mutations, + extraFileMenuItems, + newFolderAt, + onNewFolderAtChange, }: FileTreeViewProps): React.ReactElement { // Default fully collapsed; persisted per-scope so expand state survives // reloads but doesn't leak across scopes (matches StorageBrowser). @@ -110,6 +187,259 @@ export function FileTreeView({ return next; }); }; + // Optimistic expand-state re-key after a rename/move dispatch — + // harmless if the server-side move fails (a stale entry just means + // a collapsed folder). + const rekeyExpanded = (oldPath: string, newPath: string) => { + setExpanded((prev) => { + const next = new Set(); + for (const p of prev) { + if (p === oldPath) next.add(newPath); + else if (p.startsWith(oldPath + "/")) next.add(newPath + p.slice(oldPath.length)); + else next.add(p); + } + return next; + }); + }; + + // Inline rename target. One at a time, like StorageBrowser. + const [renaming, setRenaming] = useState<{kind: "file" | "folder"; path: string} | null>(null); + // In-flight drag payload (for row dimming + the move-to-root strip). + const [dragKeys, setDragKeys] = useState(null); + const [dragFolder, setDragFolder] = useState(null); + // Folder path currently hovered by an accepted drag. + const [dropTarget, setDropTarget] = useState(null); + // Keyboard-navigation focus, keyed `folder:` / `file:`. + // Pointer interactions move it too, so arrows continue from the + // last clicked row (mirrors StorageBrowser). + const [focusedKey, setFocusedKey] = useState(null); + // Anchor for shift-click / shift+arrow range selection — the last + // file row toggled by any input path (same semantics as + // StorageBrowser's lastSelectedRef). + const lastToggledRef = useRef(null); + const wrapRef = useRef(null); + useEffect(() => { + if (!focusedKey) return; + const el = wrapRef.current?.querySelector( + `[data-rowkey="${CSS.escape(focusedKey)}"]`, + ) as HTMLElement | null; + el?.scrollIntoView({block: "nearest"}); + }, [focusedKey]); + + // Hidden input backing the folder menu's "Upload here…". Clicked + // synchronously inside the menu item's onClick so the file picker + // keeps the user-activation gesture (iOS Safari refuses otherwise). + const uploadInputRef = useRef(null); + const uploadTargetRef = useRef(""); + + const acceptsDrop = (e: React.DragEvent) => + !!mutations && + (e.dataTransfer.types.includes(TREE_KEYS_MIME) || + e.dataTransfer.types.includes(TREE_FOLDER_MIME) || + (!!mutations.uploadTo && e.dataTransfer.types.includes("Files"))); + + const onDragStartFile = (key: string) => (e: React.DragEvent) => { + // Dragging a selected row drags the whole selection; dragging + // an unselected row drags just that file (matches StorageBrowser). + const keys = selection?.selected.has(key) + ? Array.from(selection.selected) + : [key]; + e.dataTransfer.setData(TREE_KEYS_MIME, JSON.stringify({scope, keys})); + e.dataTransfer.effectAllowed = "move"; + setDragKeys(keys); + }; + const onDragStartFolder = (path: string) => (e: React.DragEvent) => { + e.dataTransfer.setData(TREE_FOLDER_MIME, JSON.stringify({scope, path})); + e.dataTransfer.effectAllowed = "move"; + setDragFolder(path); + }; + const clearDragState = () => { + setDragKeys(null); + setDragFolder(null); + setDropTarget(null); + }; + + // Drop onto a folder path ("" = root). Internal drags move keys or + // a whole folder subtree; OS-file drops upload into the folder. + const handleDrop = (target: string, e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if (!mutations) return; + const rawFolder = e.dataTransfer.getData(TREE_FOLDER_MIME); + if (rawFolder) { + let p: {scope?: unknown; path?: unknown}; + try { + p = JSON.parse(rawFolder); + } catch { + return; + } + if (p.scope !== scope || typeof p.path !== "string") return; + const folderPath = p.path; + // No-ops: into itself, into its own subtree, or where it + // already lives. + if (target === folderPath || target.startsWith(folderPath + "/")) return; + if (dirnameOf(folderPath) === target) return; + const base = basenameOf(folderPath); + rekeyExpanded(folderPath, target ? `${target}/${base}` : base); + mutations.moveFolder(folderPath, target); + return; + } + const rawKeys = e.dataTransfer.getData(TREE_KEYS_MIME); + if (rawKeys) { + let p: {scope?: unknown; keys?: unknown}; + try { + p = JSON.parse(rawKeys); + } catch { + return; + } + if (p.scope !== scope || !Array.isArray(p.keys)) return; + const keys = p.keys.filter( + (k): k is string => typeof k === "string" && dirnameOf(k) !== target, + ); + if (keys.length > 0) mutations.moveKeys(keys, target); + return; + } + if (e.dataTransfer.files?.length && mutations.uploadTo) { + mutations.uploadTo(target, Array.from(e.dataTransfer.files)); + } + }; + + // ── Commit handlers (validate, then dispatch to the caller) ───── + const onRenameFileCommit = (key: string, displayName: string, raw: string) => { + setRenaming(null); + if (!mutations) return; + const name = raw.trim(); + if (!name || name === displayName) return; + if (name.includes("/")) { + window.alert("Name must not contain '/' — use Move to folder… instead"); + return; + } + mutations.renameFile(key, name); + }; + const onRenameFolderCommit = (path: string, raw: string) => { + setRenaming(null); + if (!mutations) return; + const name = raw.trim().replace(/^\/+|\/+$/g, ""); + if (!name || name === basenameOf(path)) return; + if (name.includes("/")) { + window.alert("Rename must be a single name; use Move folder into… for nested moves"); + return; + } + const parent = dirnameOf(path); + rekeyExpanded(path, parent ? `${parent}/${name}` : name); + mutations.renameFolder(path, name); + }; + const onCreateFolderCommit = (parent: string, raw: string) => { + onNewFolderAtChange?.(null); + if (!mutations) return; + const name = raw.trim().replace(/^\/+|\/+$/g, ""); + if (!name) return; + if (name.includes("/")) { + window.alert("Folder name must not contain '/'"); + return; + } + setExpanded((prev) => { + const next = new Set(prev); + if (parent) next.add(parent); + next.add(parent ? `${parent}/${name}` : name); + return next; + }); + mutations.createFolder(parent, name); + }; + + // ── Kebab menu builders ────────────────────────────────────────── + const fileMenuItems = (key: string): KebabMenuItem[] => { + if (!mutations) return []; + const items: KebabMenuItem[] = []; + if (mutations.downloadFile) { + items.push({ + key: "download", + label: "Download", + onClick: () => mutations.downloadFile!(key), + }); + } + items.push({ + key: "rename", + label: "Rename…", + onClick: () => setRenaming({kind: "file", path: key}), + }); + if (mutations.requestMoveFile) { + items.push({ + key: "move-to-folder", + label: "Move to folder…", + onClick: () => mutations.requestMoveFile!(key), + }); + } + items.push(...(extraFileMenuItems?.(key) ?? [])); + items.push({ + key: "delete", + label: "Delete", + destructive: true, + separatorBefore: true, + onClick: () => mutations.deleteFile(key), + }); + return items; + }; + const folderMenuItems = (path: string, fileCount: number): KebabMenuItem[] => { + if (!mutations) return []; + const items: KebabMenuItem[] = []; + if (mutations.uploadTo) { + items.push({ + key: "upload-here", + label: "Upload here…", + onClick: () => { + uploadTargetRef.current = path; + uploadInputRef.current?.click(); + }, + }); + } + items.push({ + key: "new-subfolder", + label: "New subfolder…", + onClick: () => { + setExpanded((prev) => new Set(prev).add(path)); + onNewFolderAtChange?.(path); + }, + }); + items.push({ + key: "rename", + label: "Rename folder…", + title: "Sibling-name rename. Subfolders preserved.", + onClick: () => setRenaming({kind: "folder", path}), + }); + if (mutations.requestMoveFolder) { + items.push({ + key: "move-into", + label: "Move folder into…", + title: "Move under a destination prefix. Subfolders preserved.", + onClick: () => mutations.requestMoveFolder!(path), + }); + } + items.push({ + key: "delete", + label: `Delete folder (${fileCount} file${fileCount === 1 ? "" : "s"})`, + destructive: true, + separatorBefore: true, + onClick: () => mutations.deleteFolder(path, fileCount), + }); + return items; + }; + + const newFolderInputRow = (parent: string, depth: number) => ( +
  • + + onCreateFolderCommit(parent, v)} + onCancel={() => onNewFolderAtChange?.(null)} + /> +
  • + ); const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => { const indentPx = depth * 12; @@ -119,13 +449,44 @@ export function FileTreeView({ const checked = selection ? selection.selected.has(key) : false; const onRowSelect = () => { if (!selection || disabled) return; + lastToggledRef.current = key; selection.onSelect([key], !checked); }; + // Shift+click selects the visible range from the anchor to + // this row (adds, never removes — matches StorageBrowser). + // ``flatRows`` is initialized before render evaluates this + // closure, so referencing it here is safe. + const onRowClick = (e: React.MouseEvent) => { + setFocusedKey(`file:${key}`); + if (!selection || disabled) return; + if (e.shiftKey && lastToggledRef.current && lastToggledRef.current !== key) { + const fileKeys: string[] = []; + for (const r of flatRows) { + if (r.kind === "file" && !r.disabled) fileKeys.push(r.key); + } + const a = fileKeys.indexOf(lastToggledRef.current); + const b = fileKeys.indexOf(key); + if (a >= 0 && b >= 0) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + selection.onSelect(fileKeys.slice(lo, hi + 1), true); + lastToggledRef.current = key; + return; + } + } + onRowSelect(); + }; + const isRenaming = renaming?.kind === "file" && renaming.path === key; + const menuItems = fileMenuItems(key); + const rowKey = `file:${key}`; return (
  • ({ : "hover:bg-gray-800/60 ") } style={{paddingLeft: 8 + indentPx}} - onClick={onRowSelect} + onClick={onRowClick} + draggable={(!!mutations && !isRenaming) || undefined} + onDragStart={mutations ? onDragStartFile(key) : undefined} + onDragEnd={mutations ? clearDragState : undefined} + // Internal drops on a file row are a no-op (folders are + // the targets) — swallowed so they don't bubble to the + // background handler and move to root. OS-file drops + // land in the row's folder. + onDragOver={mutations ? (e) => { + if (!acceptsDrop(e)) return; + e.preventDefault(); + e.stopPropagation(); + } : undefined} + onDrop={mutations ? (e) => { + e.preventDefault(); + e.stopPropagation(); + clearDragState(); + if ( + e.dataTransfer.getData(TREE_KEYS_MIME) || + e.dataTransfer.getData(TREE_FOLDER_MIME) + ) return; + if (e.dataTransfer.files?.length && mutations.uploadTo) { + mutations.uploadTo(dirnameOf(key), Array.from(e.dataTransfer.files)); + } + } : undefined} > {selection && ( ({ /> )} - - {node.displayName} - + {isRenaming ? ( + onRenameFileCommit(key, node.displayName, v)} + onCancel={() => setRenaming(null)} + /> + ) : ( + + {node.displayName} + + )} {renderFileTail && ( e.stopPropagation()}> {renderFileTail(node.file)} )} + {menuItems.length > 0 && ( + e.stopPropagation()}> + {key}} + items={menuItems} + /> + + )}
  • ); } @@ -171,18 +575,43 @@ export function FileTreeView({ if (!selection || total === 0) return; selection.onSelect(fileKeys, !allSelected); }; + const isRenaming = renaming?.kind === "folder" && renaming.path === node.path; + const menuItems = folderMenuItems(node.path, total); + const isDropTarget = dropTarget === node.path; + const rowKey = `folder:${node.path}`; return ( - +
  • toggleFolder(node.path)} + onClick={() => { + setFocusedKey(rowKey); + toggleFolder(node.path); + }} role="button" aria-expanded={isOpen} aria-label={`${isOpen ? "Collapse" : "Expand"} folder ${node.name}`} + draggable={(!!mutations && !isRenaming) || undefined} + onDragStart={mutations ? onDragStartFolder(node.path) : undefined} + onDragEnd={mutations ? clearDragState : undefined} + onDragOver={mutations ? (e) => { + if (!acceptsDrop(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + setDropTarget(node.path); + } : undefined} + onDragLeave={mutations ? () => { + setDropTarget((prev) => (prev === node.path ? null : prev)); + } : undefined} + onDrop={mutations ? (e) => handleDrop(node.path, e) : undefined} > {selection && ( ({ ) : ( )} - - {node.name}/ + {isRenaming ? ( + onRenameFolderCommit(node.path, v)} + onCancel={() => setRenaming(null)} + /> + ) : ( + + {node.name}/ + + )} + + {total === 0 ? "empty" : total} - {total} + {menuItems.length > 0 && ( + e.stopPropagation()}> + {node.path}/} + items={menuItems} + /> + + )}
  • + {isOpen && newFolderAt === node.path && newFolderInputRow(node.path, depth + 1)} {isOpen && node.children.map((c) => renderNode(c, depth + 1))}
    ); }; + const showRootDropStrip = + (dragKeys !== null && dragKeys.some((k) => dirnameOf(k) !== "")) || + (dragFolder !== null && dirnameOf(dragFolder) !== ""); + + // ── Keyboard navigation over the visible rows ──────────────────── + // Flattened render order of what's currently on screen. Shift+ + // Arrow extends the selection while moving focus (multi-select + // without a pointer); plain arrows just move focus. Space toggles + // the focused file; Enter toggles a folder; Delete removes the + // selection (or the focused row when nothing is selected). + type FlatRow = + | {kind: "folder"; path: string; count: number} + | {kind: "file"; key: string; disabled: boolean}; + const flatRows: FlatRow[] = []; + { + const walk = (ns: FileTreeNode[]) => { + for (const n of ns) { + if (n.kind === "folder") { + flatRows.push({ + kind: "folder", + path: n.path, + count: collectFileKeys(n, getKey).length, + }); + if (expanded.has(n.path)) walk(n.children); + } else { + const k = getKey(n.file); + flatRows.push({ + kind: "file", + key: k, + disabled: isDisabled?.(n.file) ?? false, + }); + } + } + }; + walk(nodes); + } + const rowKeyOf = (r: FlatRow) => (r.kind === "folder" ? `folder:${r.path}` : `file:${r.key}`); + + const onListKeyDown = (e: React.KeyboardEvent) => { + if (flatRows.length === 0) return; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", " ", "Delete"].includes(e.key)) return; + // Don't steal keys from the inline rename/new-folder inputs. + if ((e.target as HTMLElement).tagName === "INPUT") return; + e.preventDefault(); + e.stopPropagation(); + const idx = focusedKey ? flatRows.findIndex((r) => rowKeyOf(r) === focusedKey) : -1; + const row = idx >= 0 ? flatRows[idx] : null; + const selectRow = (r: FlatRow | null) => { + if (!selection || !r || r.kind !== "file" || r.disabled) return; + lastToggledRef.current = r.key; + selection.onSelect([r.key], true); + }; + const focusAt = (i: number, extendSelection: boolean) => { + const clamped = Math.max(0, Math.min(flatRows.length - 1, i)); + if (extendSelection) { + // Anchor the range on the row we're leaving, then take + // the row we land on with us. + selectRow(row); + selectRow(flatRows[clamped]); + } + setFocusedKey(rowKeyOf(flatRows[clamped])); + }; + switch (e.key) { + case "ArrowDown": + focusAt(idx < 0 ? 0 : idx + 1, e.shiftKey); + break; + case "ArrowUp": + focusAt(idx < 0 ? flatRows.length - 1 : idx - 1, e.shiftKey); + break; + case "ArrowRight": + if (row?.kind === "folder" && !expanded.has(row.path)) toggleFolder(row.path); + break; + case "ArrowLeft": + if (row?.kind === "folder" && expanded.has(row.path)) toggleFolder(row.path); + break; + case "Enter": + if (row?.kind === "folder") toggleFolder(row.path); + break; + case " ": + if (row?.kind === "file" && selection && !row.disabled) { + lastToggledRef.current = row.key; + selection.onSelect([row.key], !selection.selected.has(row.key)); + } + break; + case "Delete": + if (!mutations) break; + if (selection && selection.selected.size > 0) { + // A selection takes precedence over the focused row — + // no deleteKeys handler means no keyboard bulk delete + // (never silently fall back to the focused file). + mutations.deleteKeys?.(Array.from(selection.selected)); + break; + } + if (row?.kind === "file" && !row.disabled) { + mutations.deleteFile(row.key); + } else if (row?.kind === "folder") { + mutations.deleteFolder(row.path, row.count); + } + break; + } + }; + return ( -
      - {nodes.map((n) => renderNode(n, 0))} -
    +
    { + if (acceptsDrop(e)) e.preventDefault(); + } : undefined} + onDrop={mutations ? (e) => handleDrop("", e) : undefined} + > + {mutations?.uploadTo && ( + { + const picked = Array.from(e.target.files ?? []); + e.target.value = ""; + if (picked.length > 0) { + mutations.uploadTo!(uploadTargetRef.current, picked); + } + }} + /> + )} + {showRootDropStrip && ( +
    { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => handleDrop("", e)} + > + Drop here to move to root / +
    + )} +
      + {newFolderAt === "" && newFolderInputRow("", 0)} + {nodes.map((n) => renderNode(n, 0))} +
    +
    ); } diff --git a/src/frontend/src/components/common/FolderPickerModal.tsx b/src/frontend/src/components/common/FolderPickerModal.tsx index e6e331c4b..983623b2e 100644 --- a/src/frontend/src/components/common/FolderPickerModal.tsx +++ b/src/frontend/src/components/common/FolderPickerModal.tsx @@ -33,13 +33,21 @@ export interface FolderPickerModalProps { /** Button label — defaults to "Move". Folder-rename / move-into * flows pass a more specific verb. */ submitLabel?: string; + /** Offer a "Top level (root)" choice and make it the default. + * Used by upload-destination prompts where root is the common + * case; ``onPick`` receives ``""`` when it's chosen. Move flows + * leave this off (a move to "" is handled by drag-to-root). */ + allowRoot?: boolean; } +type PickMode = "root" | "existing" | "new"; + const FolderPickerModal: React.FC = ({ - open, title, existingFolders, initialNew, onCancel, onPick, submitLabel, + open, title, existingFolders, initialNew, onCancel, onPick, submitLabel, allowRoot, }) => { const hasExisting = existingFolders.length > 0; - const [mode, setMode] = useState<"existing" | "new">(hasExisting ? "existing" : "new"); + const defaultMode: PickMode = allowRoot ? "root" : hasExisting ? "existing" : "new"; + const [mode, setMode] = useState(defaultMode); const [selected, setSelected] = useState(existingFolders[0] ?? ""); const [newPath, setNewPath] = useState(initialNew ?? ""); const newInputRef = useRef(null); @@ -49,10 +57,10 @@ const FolderPickerModal: React.FC = ({ // would see stale state. useEffect(() => { if (!open) return; - setMode(hasExisting ? "existing" : "new"); + setMode(defaultMode); setSelected(existingFolders[0] ?? ""); setNewPath(initialNew ?? ""); - }, [open, hasExisting, existingFolders, initialNew]); + }, [open, defaultMode, existingFolders, initialNew]); // Focus the new-folder input the moment the user switches to // that mode, mirroring the prompt() ergonomics. @@ -65,6 +73,10 @@ const FolderPickerModal: React.FC = ({ if (!open) return null; const submit = () => { + if (mode === "root") { + onPick(""); + return; + } const value = mode === "existing" ? selected : newPath; const trimmed = value.trim().replace(/^\/+|\/+$/g, ""); if (!trimmed) return; @@ -73,7 +85,10 @@ const FolderPickerModal: React.FC = ({ return createPortal(
    { // Click on backdrop dismisses. Stop propagation // inside the panel itself so a stray mousedown there @@ -89,6 +104,21 @@ const FolderPickerModal: React.FC = ({ {title}
    + {allowRoot && ( + + )} {hasExisting && (
    + ); +}; + +export default FacePickingToggle; diff --git a/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx b/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx new file mode 100644 index 000000000..cf93715a2 --- /dev/null +++ b/src/frontend/src/components/info_box_scene/MeshDistortionSection.tsx @@ -0,0 +1,244 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {useMeshPanelStore} from "@/state/meshPanelStore"; +import {useOptionsStore} from "@/state/optionsStore"; +import CollapsibleSection from "@/components/common/CollapsibleSection"; +import {collectGeomEntries, focusGeomEntry, endGeomWalk, type GeomEntry} from "@/utils/scene/galleryWalk"; +import {queryNameFromRangeId} from "@/utils/mesh_select/queryMeshDrawRange"; +import {refreshEdgeOverlays} from "@/utils/scene/refreshEdgeOverlays"; + +// "Mesh" mode of the Scene panel (Scene dropdown → Mesh). Scans every geom in the scene for +// "crows-nest" tessellation spikes — the same detector the gallery "distorted" walk uses +// (utils/mesh_select/meshStats) — and lists the offenders in a distortion-sorted table. The two +// spike thresholds are editable; "Rescan" re-runs at the current thresholds. Clicking a row selects +// + frames that geom (triangle edges on) so the spike is visible. Rendered inside SceneInfoBox, which +// supplies the panel chrome + the mode dropdown — so this is a bare section, no outer chrome. + +interface Row { + mesh: GeomEntry["mesh"]; + rangeId: string; + triangles: number; + spike: number; + spikeTris: number; + name: string; +} + +type SortKey = "spike" | "spikeTris" | "triangles" | "name"; + +const num = (n: number, digits = 1) => (Number.isFinite(n) ? n.toFixed(digits) : "—"); + +const MeshDistortionSection: React.FC = () => { + const {spikeAspectMin, spikeOutlierK, setSpikeAspectMin, setSpikeOutlierK, resetThresholds} = + useMeshPanelStore(); + const {showEdges, setShowEdges, hideTessellationEdges, setHideTessellationEdges} = useOptionsStore(); + + const [rows, setRows] = useState([]); + const [scanning, setScanning] = useState(false); + const [scanned, setScanned] = useState(false); + const [isolate, setIsolate] = useState(false); + const [sortKey, setSortKey] = useState("spike"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [selectedRange, setSelectedRange] = useState(null); + const scanSeq = useRef(0); + + const rescan = useCallback(async () => { + const seq = ++scanSeq.current; + setScanning(true); + const entries = collectGeomEntries("distorted", {spikeAspectMin, spikeOutlierK}); + if (scanSeq.current !== seq) return; + const base: Row[] = entries.map((e) => ({ + mesh: e.mesh, + rangeId: e.rangeId, + triangles: e.triangles, + spike: e.spike, + spikeTris: e.spikeTris, + name: e.rangeId, + })); + setRows(base); + setScanning(false); + setScanned(true); + const names = await Promise.all( + base.map((r) => queryNameFromRangeId(r.mesh.unique_key, r.rangeId).catch(() => null)), + ); + if (scanSeq.current !== seq) return; + setRows(base.map((r, i) => ({...r, name: names[i] || r.rangeId}))); + }, [spikeAspectMin, spikeOutlierK]); + + // Scan once when the Mesh mode is opened. Threshold edits don't auto-rescan (avoid a heavy + // full-scene scan on every keystroke) — the user hits Rescan. + useEffect(() => { + void rescan(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sorted = useMemo(() => { + const dir = sortDir === "desc" ? -1 : 1; + return [...rows].sort((a, b) => { + if (sortKey === "name") return dir * a.name.localeCompare(b.name); + return dir * (a[sortKey] - b[sortKey]); + }); + }, [rows, sortKey, sortDir]); + + const onSort = (key: SortKey) => { + if (key === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc")); + else { + setSortKey(key); + setSortDir(key === "name" ? "asc" : "desc"); + } + }; + + const onRowClick = (r: Row) => { + setSelectedRange(r.rangeId); + void focusGeomEntry( + {mesh: r.mesh, rangeId: r.rangeId, triangles: r.triangles, density: 0, spike: r.spike, spikeTris: r.spikeTris}, + {hideUnselected: isolate, forceEdges: true}, + ); + }; + + const sortArrow = (key: SortKey) => (sortKey === key ? (sortDir === "desc" ? " ▼" : " ▲") : ""); + const thClass = "sticky top-0 bg-gray-800 px-2 py-1 text-left font-semibold cursor-pointer select-none whitespace-nowrap"; + + return ( +
    + +
    + {/* Mesh triangle visibility — see the triangulation to judge tessellation/welding. + Both rebuild the edge overlays live (no page/model reload). */} +
    + + +
    + + {/* Thresholds — edit then Rescan. */} + + +
    + + + + +
    +
    +
    + + {/* Results table — collapsible, default collapsed; y-overflow, sortable, distortion-sorted. */} + + {rows.length === 0 ? ( +
    + {scanning ? "Scanning scene…" : "No distorted geoms at these thresholds."} +
    + ) : ( +
    + + + + + + + + + + + {sorted.map((r) => ( + onRowClick(r)} + > + + + + + + ))} + +
    onSort("name")}>Geom{sortArrow("name")} onSort("spike")}>Spike{sortArrow("spike")} onSort("spikeTris")}>Spike tris{sortArrow("spikeTris")} onSort("triangles")}>Tris{sortArrow("triangles")}
    {r.name}{num(r.spike)}{r.spikeTris}{r.triangles}
    +
    + )} +
    +
    + ); +}; + +export default MeshDistortionSection; diff --git a/src/frontend/src/components/info_box_scene/SceneInfoBox.tsx b/src/frontend/src/components/info_box_scene/SceneInfoBox.tsx index 0cea5d2ec..45e17acb4 100644 --- a/src/frontend/src/components/info_box_scene/SceneInfoBox.tsx +++ b/src/frontend/src/components/info_box_scene/SceneInfoBox.tsx @@ -6,8 +6,10 @@ import LoadedModelsSection from "./LoadedModelsSection"; import StatsSection from "./StatsSection"; import GroupsSection from "./GroupsSection"; import UtilitiesSection from "./UtilitiesSection"; +import FacePickingToggle from "./FacePickingToggle"; import SectionPlanesPanel from "./SectionPlanesPanel"; import FemConceptsPanel from "./FemConceptsPanel"; +import MeshDistortionSection from "./MeshDistortionSection"; import {useSceneInfoStore} from "@/state/sceneInfoStore"; // Container for everything that talks about the currently-loaded scene @@ -26,12 +28,13 @@ const SceneInfoBox = () => {
    {/* Flat list of every loaded model, whatever storage folder @@ -42,6 +45,7 @@ const SceneInfoBox = () => { {mode === "info" ? ( <> + @@ -53,6 +57,8 @@ const SceneInfoBox = () => { ) : mode === "section" ? ( + ) : mode === "mesh" ? ( + ) : ( )} diff --git a/src/frontend/src/components/info_box_selected_object/MeshStatsSection.tsx b/src/frontend/src/components/info_box_selected_object/MeshStatsSection.tsx new file mode 100644 index 000000000..b20375b99 --- /dev/null +++ b/src/frontend/src/components/info_box_selected_object/MeshStatsSection.tsx @@ -0,0 +1,105 @@ +// Collapsible "Mesh" section for the selection inspector — tessellation +// stats for the currently-selected geom(s), computed client-side from +// the batched geometry buffer (no server round-trip). Aggregates across +// a multi-selection: total triangles / vertices / surface area, and the +// mesh density (triangles per m²) that the gallery "density" walk sorts +// by. Renders nothing when the selection has no triangle geometry. + +import React, {useState} from "react"; + +import {useViewerStores} from "@/state/AdaViewerContext"; +import {CustomBatchedMesh} from "@/utils/mesh_select/CustomBatchedMesh"; +import {computeRangeStats} from "@/utils/mesh_select/meshStats"; + +const Chevron: React.FC<{open: boolean}> = ({open}) => ( + +); + +const fmtInt = (v: number): string => Math.round(v).toLocaleString(); + +// Compact SI-ish number: keep 3 significant figures without scientific +// notation for the everyday range. +const fmtNum = (v: number): string => { + if (!isFinite(v) || v === 0) return "0"; + const abs = Math.abs(v); + if (abs >= 1000 || abs < 0.001) return v.toPrecision(3); + return v.toFixed(abs >= 100 ? 1 : abs >= 1 ? 2 : 3); +}; + +const Row: React.FC<{label: string; children: React.ReactNode}> = ({label, children}) => ( + + {label} + {children} + +); + +interface Agg { + geoms: number; + triangles: number; + vertices: number; + area: number; + volume: number; +} + +const MeshStatsSection: React.FC = () => { + const {useSelectedObjectStore} = useViewerStores(); + const selectedObjects = useSelectedObjectStore((s) => s.selectedObjects); + const [expanded, setExpanded] = useState(false); + + const agg: Agg = {geoms: 0, triangles: 0, vertices: 0, area: 0, volume: 0}; + selectedObjects.forEach((rangeIds, obj) => { + if (!(obj instanceof CustomBatchedMesh)) return; + rangeIds.forEach((rangeId) => { + const s = computeRangeStats(obj, rangeId); + if (!s) return; + agg.geoms += 1; + agg.triangles += s.triangles; + agg.vertices += s.vertices; + agg.area += s.area; + agg.volume += s.volume; + }); + }); + + if (agg.geoms === 0) return null; + + const density = agg.area > 0 ? agg.triangles / agg.area : 0; + + return ( +
    + + {expanded && ( +
    + + + {agg.geoms > 1 && {fmtInt(agg.geoms)}} + {fmtInt(agg.triangles)} + {fmtInt(agg.vertices)} + {fmtNum(agg.area)} m² + {fmtNum(agg.volume)} m³ + {fmtNum(density)} tris/m² + +
    +
    + )} +
    + ); +}; + +export default MeshStatsSection; diff --git a/src/frontend/src/components/info_box_selected_object/ObjectMetadataPanel.tsx b/src/frontend/src/components/info_box_selected_object/ObjectMetadataPanel.tsx index bd43bf302..a48c00a50 100644 --- a/src/frontend/src/components/info_box_selected_object/ObjectMetadataPanel.tsx +++ b/src/frontend/src/components/info_box_selected_object/ObjectMetadataPanel.tsx @@ -2,11 +2,23 @@ import React, {useState} from 'react'; import {selectInOtherModel} from '@/utils/scene/crossModelSelect'; import type {LinkResult} from '@/state/lineageStore'; import {useViewerStores} from '@/state/AdaViewerContext'; +import {useOptionsStore} from '@/state/optionsStore'; +import RowKebabMenu, {KebabMenuItem} from '@/components/common/RowKebabMenu'; +import {writeToClipboard} from '@/utils/clipboard/copySelectionNames'; import ConnectionsSection from './ConnectionsSection'; +import MeshStatsSection from './MeshStatsSection'; -// Decimal places for the "Clicked at" coordinates, matching the -// precision the old standalone block used before the fold-in. -const COORD_PREC = 3; +// Decimal-place choices offered in the Properties kebab menu for the "Clicked at" row. A small +// model (a few units across) needs more decimals before nearby clicks show a different position. +const DECIMAL_CHOICES = [1, 2, 3, 4, 6, 8]; +const COPIED_FEEDBACK_MS = 1500; + +const CheckIcon: React.FC = () => ( + + + +); const Chevron: React.FC<{open: boolean}> = ({open}) => ( { const {useObjectInfoStore, useModelState} = useViewerStores(); const clickCoord = useObjectInfoStore((s) => s.clickCoordinate); const zIsUp = useModelState((s) => s.zIsUp); + const decimals = useOptionsStore((s) => s.clickedCoordDecimals); + const [copied, setCopied] = useState(false); if (!clickCoord) return null; - const x = clickCoord.x.toFixed(COORD_PREC); - const y = clickCoord.y.toFixed(COORD_PREC); - const z = clickCoord.z.toFixed(COORD_PREC); + const x = clickCoord.x.toFixed(decimals); + const y = clickCoord.y.toFixed(decimals); + const z = clickCoord.z.toFixed(decimals); // Mirror the original axis-swap convention from CoordinateDisplay: // viewer scene is y-up by default, but adapy's Z-up world is what // users think in — so when zIsUp is set, show the world tuple // (x, y, z) directly; otherwise re-order to compensate. - const display = zIsUp ? `(${x}, ${y}, ${z})` : `(${x}, ${z}, ${y})`; - return {display}; + const ordered = zIsUp ? [x, y, z] : [x, z, y]; + const display = `(${ordered.join(', ')})`; + // Clipboard gets the same numbers in the same displayed order, but as bare + // comma-separated floats (no parentheses) so they paste straight into code. + const onCopy = () => { + void writeToClipboard(ordered.join(', ')).then((ok) => { + if (!ok) return; + setCopied(true); + window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS); + }); + }; + return ( + + + + ); +}; + +// Source face of the last click — shown only when face-level picking is on and a region resolved. +// The #id is the STEP ADVANCED_FACE / IFC IfcFace entity id, so a mis-tessellated face can be named +// exactly (not just "somewhere on this solid"); copyable for pasting into a bug report. +const ClickedFaceRow: React.FC = () => { + const {useObjectInfoStore} = useViewerStores(); + const face = useObjectInfoStore((s) => s.clickedFace); + const [copied, setCopied] = useState(false); + if (!face) return null; + const label = `#${face.faceId} (face ${face.seq})`; + const onCopy = () => { + void writeToClipboard(`#${face.faceId}`).then((ok) => { + if (!ok) return; + setCopied(true); + window.setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS); + }); + }; + return ( + + + + ); }; const ObjectMetadataPanel: React.FC = ({data}) => { const {useObjectInfoStore, useLineageStore} = useViewerStores(); + const showMeshStats = useOptionsStore((s) => s.showMeshStats); // Default collapsed: most clicks are just for selection / hide / // jump, not for inspecting properties. Folding keeps the info box // compact and the chevron tells the user where the data lives. @@ -239,18 +305,35 @@ const ObjectMetadataPanel: React.FC = ({data}) => { // coordinate row and the cross-model link buttons. const meta = (effectiveData ?? null) as BeamMeta | PlateMeta | CurvedPlateMeta | null; const known = meta?.type === 'Beam' || meta?.type === 'Plate' || meta?.type === 'PlateCurved'; + const decimals = useOptionsStore((s) => s.clickedCoordDecimals); + const setDecimals = useOptionsStore((s) => s.setClickedCoordDecimals); + const decimalItems: KebabMenuItem[] = DECIMAL_CHOICES.map((d) => ({ + key: `dec-${d}`, + label: `${d} decimal${d === 1 ? '' : 's'}`, + // A check on the active choice; a blank same-width spacer keeps the labels aligned. + icon: d === decimals ? : , + onClick: () => setDecimals(d), + })); return (
    - +
    + + +
    {expanded && (
    {meta && !known && {(meta as any).type ?? 'Unknown'}} @@ -277,9 +360,15 @@ const ObjectMetadataPanel: React.FC = ({data}) => { )} + {link && }
    )} + {expanded && showMeshStats && ( +
    + +
    + )}
    ); diff --git a/src/frontend/src/components/options/DisplayOptions.tsx b/src/frontend/src/components/options/DisplayOptions.tsx index d8ae96f26..c16e3e82b 100644 --- a/src/frontend/src/components/options/DisplayOptions.tsx +++ b/src/frontend/src/components/options/DisplayOptions.tsx @@ -2,6 +2,7 @@ import React from "react"; import {useOptionsStore} from "@/state/optionsStore"; import {useColorStore} from "@/state/colorLegendStore"; import {useModelState} from "@/state/modelState"; +import {refreshEdgeOverlays} from "@/utils/scene/refreshEdgeOverlays"; const Toggle: React.FC<{ checked: boolean; @@ -17,10 +18,12 @@ const Toggle: React.FC<{ const DisplayOptions: React.FC = () => { const { showEdges, setShowEdges, + showMeshStats, setShowMeshStats, hideTessellationEdges, setHideTessellationEdges, lockTranslation, setLockTranslation, enableNodeEditor, setEnableNodeEditor, enableWebsocket, setEnableWebsocket, + autoFit, setAutoFit, autoConvertOnUpload, setAutoConvertOnUpload, } = useOptionsStore(); const {showLegend, setShowLegend} = useColorStore(); @@ -32,20 +35,28 @@ const DisplayOptions: React.FC = () => { section — it's a perf-diagnosis toggle, not a display preference. */} setShowLegend(!showLegend)}>Show Color Legend - setShowEdges(!showEdges)}>Geometry Edges + { + setShowEdges(!showEdges); + refreshEdgeOverlays(); + }} + > + Geometry Edges + {showEdges && ( )} + setShowMeshStats(!showMeshStats)}> + Mesh stats in Properties +
    + setAutoFit(!autoFit)}>Auto Fit to View setLockTranslation(!lockTranslation)}>Lock Translation setEnableNodeEditor(!enableNodeEditor)}>Enable Node Editor setEnableWebsocket(!enableWebsocket)}>Enable Websocket diff --git a/src/frontend/src/components/options/ThemeOptions.tsx b/src/frontend/src/components/options/ThemeOptions.tsx index 1eca977a2..aa821c6c4 100644 --- a/src/frontend/src/components/options/ThemeOptions.tsx +++ b/src/frontend/src/components/options/ThemeOptions.tsx @@ -5,6 +5,7 @@ import { ThemePresetId, useThemeStore, } from "@/state/themeStore"; +import {useGalleryStore} from "@/state/galleryStore"; // Theme picker for the menu-row panels. Four preset cards (each a // live mini-preview of its chrome) plus custom swatches for panel @@ -22,11 +23,28 @@ const ThemeOptions: React.FC = () => { const setBgOpacity = useThemeStore((s) => s.setBgOpacity); const resetCustom = useThemeStore((s) => s.resetCustom); + const galleryEnabled = useGalleryStore((s) => s.enabled); + const setGalleryEnabled = useGalleryStore((s) => s.setEnabled); + const hasCustom = customBg !== null || customText !== null; const effective = effectivePanelTheme({preset, customBg, customText, bgOpacity}); return (
    +
    {(Object.keys(THEME_PRESETS) as ThemePresetId[]).map((id) => { const p = THEME_PRESETS[id]; diff --git a/src/frontend/src/components/storage/StorageBrowser.tsx b/src/frontend/src/components/storage/StorageBrowser.tsx index e7a0b79c7..fdf9ec3ee 100644 --- a/src/frontend/src/components/storage/StorageBrowser.tsx +++ b/src/frontend/src/components/storage/StorageBrowser.tsx @@ -30,10 +30,12 @@ import { FolderNode, loadExpandedFolders, loadPendingFolders, + previewKeyList, saveExpandedFolders, savePendingFolders, } from "@/utils/storage/fileTree"; import {RowKebabMenu} from "@/components/common/RowKebabMenu"; +import InlineNameInput from "@/components/common/InlineNameInput"; import PositionedMenu, {KebabMenuItem} from "@/components/common/PositionedMenu"; import FolderPickerModal from "@/components/common/FolderPickerModal"; import {viewerApi} from "@/services/viewerApi"; @@ -212,54 +214,6 @@ function formatRelative(iso: string): string { return new Date(t).toISOString().slice(0, 10); } -// Inline name editor used for file/folder rename and new-folder -// creation. Enter commits, Escape (or blur) cancels. ``selectStem`` -// pre-selects the basename-without-extension so a quick type replaces -// the name but keeps the extension. -const InlineNameInput: React.FC<{ - initial: string; - placeholder?: string; - selectStem?: boolean; - onCommit: (value: string) => void; - onCancel: () => void; -}> = ({initial, placeholder, selectStem, onCommit, onCancel}) => { - const inputRef = useRef(null); - useEffect(() => { - const el = inputRef.current; - if (!el) return; - el.focus(); - if (selectStem) { - const dot = initial.lastIndexOf("."); - el.setSelectionRange(0, dot > 0 ? dot : initial.length); - } else { - el.select(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( - e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - onCommit((e.target as HTMLInputElement).value); - } else if (e.key === "Escape") { - onCancel(); - } - }} - onBlur={onCancel} - /> - ); -}; - const StorageBrowser: React.FC = () => { const files = useServerInfoStore((s) => s.serverFileObjects); const {sidecars} = useBuildSidecars(files); @@ -444,10 +398,13 @@ const StorageBrowser: React.FC = () => { const mutations = useStorageMutations(); const canMutate = mutations.canMutate; - // The picker modal drives both move flows; ``onPick`` is the - // closure that knows what to do once a destination is chosen. + // The picker modal drives the move flows and the upload-destination + // prompt; ``onPick`` is the closure that knows what to do once a + // destination is chosen. const [picker, setPicker] = useState<{ title: string; + allowRoot?: boolean; + submitLabel?: string; onPick: (folder: string) => Promise | void; } | null>(null); // Download a stored blob with auth (REST mode). The suggested filename is the @@ -460,27 +417,69 @@ const StorageBrowser: React.FC = () => { window.alert(e instanceof Error ? e.message : String(e)); }; + // In-flight move status — a spinner line under the header so a + // drag-drop of many files visibly runs until the listing refreshes. + // Moves are chunked purely so the counter ticks between requests; + // every chunk is still a server-side S3 rename (CopyObject+Delete + // on Garage) — no file bytes pass through the browser. The ref + // rejects overlapping batches (concurrent moves would race on the + // server-side collision checks). + const [opNote, setOpNote] = useState(null); + const opBusyRef = useRef(false); + const OP_CHUNK = 8; + const moveKeysWithProgress = async (keys: string[], folder: string) => { + if (opBusyRef.current || keys.length === 0) return; + opBusyRef.current = true; + const label = folder ? `${folder}/` : "root /"; + setOpNote(`Moving 0/${keys.length} to ${label}…`); + try { + if (folder === "") { + // Move-to-root: the move endpoint requires a non-empty + // folder, so root moves are per-key renames to the + // basename. + let done = 0; + for (const k of keys) { + setOpNote(`Moving ${done + 1}/${keys.length} to ${label}…`); + await mutations.renameKey(k, basenameOf(k)); + done++; + } + } else { + const failed: Array<{key: string; reason: string}> = []; + for (let i = 0; i < keys.length; i += OP_CHUNK) { + const chunk = keys.slice(i, i + OP_CHUNK); + setOpNote(`Moving ${Math.min(i + chunk.length, keys.length)}/${keys.length} to ${label}…`); + const r = await mutations.moveKeys(chunk, folder); + failed.push(...r.failed); + } + if (failed.length > 0) { + window.alert(failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); + } + } + clearSelection(); + void request_list_of_files_from_server(); + } catch (e) { + alertError(e); + } finally { + opBusyRef.current = false; + setOpNote(null); + } + }; + const onMoveSingleToFolder = (key: string) => { setPicker({ title: `Move "${key}" to folder`, - onPick: async (folder) => { - try { - const r = await mutations.moveKeys([key], folder); - if (r.failed.length > 0) { - window.alert(r.failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); - } - void request_list_of_files_from_server(); - } catch (e) { - alertError(e); - } - }, + onPick: (folder) => moveKeysWithProgress([key], folder), }); }; const runFolderMove = async (folderPath: string, newPath: string) => { if (newPath === folderPath) return; + if (opBusyRef.current) return; + opBusyRef.current = true; + const allKeys = files.map((f) => f.name); + const count = allKeys.filter((k) => k.replace(/^\/+/, "").startsWith(folderPath + "/")).length; + setOpNote(`Moving folder "${folderPath}" → "${newPath}" (${count} file${count === 1 ? "" : "s"})…`); try { - const allKeys = files.map((f) => f.name); const r = await mutations.renameOrMoveFolder(folderPath, newPath, allKeys); if (r.failed.length > 0) { window.alert(r.failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); @@ -495,6 +494,9 @@ const StorageBrowser: React.FC = () => { void request_list_of_files_from_server(); } catch (e) { alertError(e); + } finally { + opBusyRef.current = false; + setOpNote(null); } }; @@ -574,12 +576,13 @@ const StorageBrowser: React.FC = () => { removePendingFoldersUnder(path); return; } + const prefix = path + "/"; + const targets = files.filter((x) => x.name.replace(/^\/+/, "").startsWith(prefix)); if (!window.confirm( `Delete folder "${path}" and its ${fileCount} file${fileCount === 1 ? "" : "s"}?\n` + - "Converted view caches are removed too.", + "Converted view caches are removed too.\n\n" + + previewKeyList(targets.map((t) => t.name)), )) return; - const prefix = path + "/"; - const targets = files.filter((x) => x.name.replace(/^\/+/, "").startsWith(prefix)); try { // Sequential: each delete cascades derived blobs server-side // and parallel calls would race on the storage listing. @@ -712,7 +715,8 @@ const StorageBrowser: React.FC = () => { if (keys.length === 0) return; if (!window.confirm( `Delete ${keys.length} file${keys.length === 1 ? "" : "s"}?\n` + - "Converted view caches are removed too.", + "Converted view caches are removed too.\n\n" + + previewKeyList(keys), )) return; setBulkBusy("delete"); try { @@ -735,18 +739,7 @@ const StorageBrowser: React.FC = () => { if (keys.length === 0) return; setPicker({ title: `Move ${keys.length} file${keys.length === 1 ? "" : "s"} to folder`, - onPick: async (folder) => { - try { - const r = await mutations.moveKeys(keys, folder); - if (r.failed.length > 0) { - window.alert(r.failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); - } - clearSelection(); - void request_list_of_files_from_server(); - } catch (e) { - alertError(e); - } - }, + onPick: (folder) => moveKeysWithProgress(keys, folder), }); }; @@ -803,9 +796,22 @@ const StorageBrowser: React.FC = () => { const onFilePicked = (e: React.ChangeEvent) => { const picked = Array.from(e.target.files ?? []); e.target.value = ""; - const folder = uploadTargetRef.current ?? undefined; + const folder = uploadTargetRef.current; uploadTargetRef.current = null; - void uploadFilesTo(picked, folder); + if (picked.length === 0) return; + if (folder !== null) { + // "Upload here…" on a folder row — destination already chosen. + void uploadFilesTo(picked, folder || undefined); + return; + } + // Generic "Upload files…": ask where the batch should land — an + // existing folder, a new path, or the top level (the default). + setPicker({ + title: `Upload ${picked.length} file${picked.length === 1 ? "" : "s"} to`, + allowRoot: true, + submitLabel: "Upload", + onPick: (dest) => void uploadFilesTo(picked, dest || undefined), + }); }; // ── Drag & drop ───────────────────────────────────────────────── @@ -845,26 +851,7 @@ const StorageBrowser: React.FC = () => { return; } keys = keys.filter((k) => typeof k === "string" && dirnameOf(k) !== target); - if (keys.length === 0) return; - try { - if (target === "") { - // Move-to-root: the move endpoint requires a non-empty - // folder, so root moves are per-key renames to the - // basename. - for (const k of keys) { - await mutations.renameKey(k, basenameOf(k)); - } - } else { - const r = await mutations.moveKeys(keys, target); - if (r.failed.length > 0) { - window.alert(r.failed.map((f) => `${f.key}: ${f.reason}`).join("\n")); - } - } - clearSelection(); - void request_list_of_files_from_server(); - } catch (err) { - alertError(err); - } + await moveKeysWithProgress(keys, target); return; } if (e.dataTransfer.files?.length) { @@ -956,23 +943,40 @@ const StorageBrowser: React.FC = () => { const onListKeyDown = (e: React.KeyboardEvent) => { if (flatRows.length === 0) return; - if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", " "].includes(e.key)) return; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter", " ", "Delete"].includes(e.key)) return; // Don't steal keys from the inline rename/new-folder inputs. if ((e.target as HTMLElement).tagName === "INPUT") return; e.preventDefault(); e.stopPropagation(); const idx = focusedKey ? flatRows.findIndex((r) => rowKeyOf(r) === focusedKey) : -1; const row = idx >= 0 ? flatRows[idx] : null; - const focusAt = (i: number) => { + // Shift+Arrow extends the selection while moving focus — + // multi-select without a pointer. Anchor the range on the row + // we're leaving, then take the row we land on with us. Folder + // rows just pass through (they can't be selected). + const selectFileRow = (r: FlatRow | null) => { + if (!r || r.kind !== "file") return; + setSelection((prev) => { + const next = new Set(prev); + next.add(r.name); + return next; + }); + lastSelectedRef.current = r.name; + }; + const focusAt = (i: number, extendSelection = false) => { const clamped = Math.max(0, Math.min(flatRows.length - 1, i)); + if (extendSelection) { + selectFileRow(row); + selectFileRow(flatRows[clamped]); + } setFocusedKey(rowKeyOf(flatRows[clamped])); }; switch (e.key) { case "ArrowDown": - focusAt(idx < 0 ? 0 : idx + 1); + focusAt(idx < 0 ? 0 : idx + 1, e.shiftKey); break; case "ArrowUp": - focusAt(idx < 0 ? flatRows.length - 1 : idx - 1); + focusAt(idx < 0 ? flatRows.length - 1 : idx - 1, e.shiftKey); break; case "ArrowRight": if (!row) { @@ -1000,6 +1004,33 @@ const StorageBrowser: React.FC = () => { case " ": if (row?.kind === "file") toggleSelection(row.name); break; + case "Delete": { + if (!canMutate) break; + if (selection.size > 0) { + // The selection takes precedence over the focused row. + // Version blobs are server-protected — refuse loudly + // instead of half-deleting the batch. + const hasVersions = Array.from(selection).some((k) => + k.replace(/^\/+/, "").startsWith("versions/"), + ); + if (hasVersions) { + window.alert("CI version files can't be deleted"); + break; + } + void onDeleteSelected(); + break; + } + if (!row) break; + if (row.kind === "file") { + void onDeleteFile(row.file); + } else { + const prefix = row.path + "/"; + const count = files.filter((x) => + x.name.replace(/^\/+/, "").startsWith(prefix)).length; + void onDeleteFolder(row.path, count, count === 0); + } + break; + } } }; @@ -1232,6 +1263,15 @@ const StorageBrowser: React.FC = () => {
    ); })()} + {opNote && ( +
    +
    + )} {uploadName && (
    @@ -1532,6 +1572,8 @@ const StorageBrowser: React.FC = () => { open={picker !== null} title={picker?.title ?? ""} existingFolders={existingFolderPaths} + allowRoot={picker?.allowRoot} + submitLabel={picker?.submitLabel} onCancel={() => setPicker(null)} onPick={(folder) => { const action = picker?.onPick; diff --git a/src/frontend/src/components/viewer/CanvasWrapper.tsx b/src/frontend/src/components/viewer/CanvasWrapper.tsx index b60970f62..4106686ce 100644 --- a/src/frontend/src/components/viewer/CanvasWrapper.tsx +++ b/src/frontend/src/components/viewer/CanvasWrapper.tsx @@ -4,6 +4,7 @@ import ColorLegend from "./ColorLegend"; import ThreeCanvas from "./ThreeCanvas"; import SectionPlanesController from "./SectionPlanesController"; import FemConceptsController from "./FemConceptsController"; +import GalleryControls from "./GalleryControls"; const CanvasWrapper: React.FC = () => { return ( @@ -15,6 +16,8 @@ const CanvasWrapper: React.FC = () => {
    + {/* Gallery HUD (opt-in via Theme options): prev/next over the scope's files. */} + {/* Headless: reconciles section-plane clipping/caps/gizmo with the scene. */} {/* Headless: draws the FEM-concept glyph overlay (masses / BCs / loads). */} diff --git a/src/frontend/src/components/viewer/GalleryControls.tsx b/src/frontend/src/components/viewer/GalleryControls.tsx new file mode 100644 index 000000000..239f67e06 --- /dev/null +++ b/src/frontend/src/components/viewer/GalleryControls.tsx @@ -0,0 +1,495 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {useGalleryStore} from "@/state/galleryStore"; +import {useServerInfoStore} from "@/state/serverInfoStore"; +import {useModelState} from "@/state/modelState"; +import {useObjectInfoStore} from "@/state/objectInfoStore"; +import {useLoadQueueStore} from "@/state/loadQueueStore"; +import {useScopeStore, scopeUrlPart} from "@/state/scopeStore"; +import {clear_loaded_model} from "@/utils/scene/handlers/clear_loaded_model"; +import {convertWithSelection} from "@/services/conversion"; +import {SerializerTessellatorSelect} from "@/components/convert/SerializerTessellatorSelect"; +import type {SerializerSelection} from "@/services/conversion/serializerMatrix"; +import {canLoadIntoSceneLegacy, isStreamingFEAResult} from "@/utils/scene/fileKinds"; +import {collectGeomEntries, focusGeomEntry, endGeomWalk, type GeomEntry} from "@/utils/scene/galleryWalk"; +import {writeToClipboard} from "@/utils/clipboard/copySelectionNames"; +import FilePickerModal from "@/components/common/FilePickerModal"; + +// Gallery HUD. Three "walk" types (dropdown): +// - "files": cycles the current scope's loadable files (clear → load). +// - "geoms": cycles every geom in the scene, selecting + framing each, +// ordered by scene or mesh density. +// - "tree": the same, in model-tree hierarchy order. +// Desktop: compact card top-right. Mobile: full-width bottom bar. +const GalleryControls: React.FC = () => { + const enabled = useGalleryStore((s) => s.enabled); + const walk = useGalleryStore((s) => s.walk); + const setWalk = useGalleryStore((s) => s.setWalk); + const geomOrder = useGalleryStore((s) => s.geomOrder); + const setGeomOrder = useGalleryStore((s) => s.setGeomOrder); + const hideUnselected = useGalleryStore((s) => s.hideUnselected); + const toggleHideUnselected = useGalleryStore((s) => s.toggleHideUnselected); + const setMobileBarHeight = useGalleryStore((s) => s.setMobileBarHeight); + + // Publish the mobile bar's live height so the audit toast can stack above it (not overlap). + // getBoundingClientRect().height is 0 when the bar is display:none (desktop md:hidden), so the + // offset naturally applies only on mobile. + const mobileBarRef = useRef(null); + useEffect(() => { + const el = mobileBarRef.current; + if (!el || typeof ResizeObserver === "undefined") { + setMobileBarHeight(0); + return; + } + const publish = () => setMobileBarHeight(el.getBoundingClientRect().height); + const ro = new ResizeObserver(publish); + ro.observe(el); + publish(); + return () => { + ro.disconnect(); + setMobileBarHeight(0); + }; + }, [setMobileBarHeight, enabled]); + + const fileObjects = useServerInfoStore((s) => s.serverFileObjects); + // The overlay load path registers into the plural loaded-source SET (not the + // singular loadedSourceName), so anchor + "is it shown?" checks use the set. + const loadedSources = useModelState((s) => s.loadedSourceNames); + const loadBusy = useLoadQueueStore((s) => s.current); + const selectedName = useObjectInfoStore((s) => s.name); + // A stable key for the active scope so a scope switch resets the walk (its geom entries point at + // the old scope's now-disposed meshes). + const scopeKey = useScopeStore((s) => (s.current ? scopeUrlPart(s.current) : "")); + + const isGeomWalk = walk === "geoms"; + + // ---- Files walk ------------------------------------------------------- + // The gallery walks only files the viewer can actually mount. + const files = useMemo( + () => + fileObjects + .map((f) => f.name) + .filter((n) => isStreamingFEAResult(n) || canLoadIntoSceneLegacy(n)), + [fileObjects], + ); + + const [index, setIndex] = useState(0); + + const go = useCallback( + async (next: number) => { + if (files.length === 0) return; + const wrapped = (next + files.length) % files.length; + setIndex(wrapped); + // Gallery = one at a time: clear the scene, then load the pick. + await clear_loaded_model(); + useLoadQueueStore.getState().enqueue({name: files[wrapped]}); + }, + [files], + ); + + // Keep the index anchored to whatever is actually shown: when a gallery file + // is in the scene point at it; when the file list changes (scope switch / + // refresh) clamp into range. + useEffect(() => { + if (files.length === 0) return; + const at = files.findIndex((f) => loadedSources.has(f)); + if (at >= 0) setIndex(at); + else setIndex((i) => Math.min(i, files.length - 1)); + }, [files, loadedSources]); + + // Auto-load the current pick when the FILES walk turns on with nothing from + // the scope's files already in the scene — otherwise the HUD shows a path but + // the viewer stays empty until the user clicks Next/Prev. + useEffect(() => { + if (!enabled || isGeomWalk || files.length === 0 || loadBusy) return; + const anyShown = files.some((f) => loadedSources.has(f)); + if (!anyShown) void go(index); + // Only re-evaluate on enable / file-list change, not on every index tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, isGeomWalk, files]); + + // ---- Geoms / tree walk ------------------------------------------------ + const [geomEntries, setGeomEntries] = useState([]); + const [geomIndex, setGeomIndex] = useState(0); + const focusedOnce = useRef(false); + + const rebuildGeoms = useCallback(() => { + if (!isGeomWalk) return [] as GeomEntry[]; + // Runs synchronously inside effects; a mid-transition scene must never throw here (that + // would unmount the whole app). Fall back to an empty walk on any error. + let entries: GeomEntry[] = []; + try { + entries = collectGeomEntries(geomOrder); + } catch { + entries = []; + } + setGeomEntries(entries); + return entries; + }, [isGeomWalk, geomOrder]); + + const goGeom = useCallback( + async (next: number, entriesOverride?: GeomEntry[]) => { + const entries = entriesOverride ?? geomEntries; + if (entries.length === 0) return; + const wrapped = (next + entries.length) % entries.length; + setGeomIndex(wrapped); + await focusGeomEntry(entries[wrapped], {hideUnselected, forceEdges: geomOrder === "distorted"}); + }, + [geomEntries, hideUnselected, geomOrder], + ); + + // Entering a geom walk or changing walk/order (re)builds the list and + // restarts the slideshow at the first geom — a predictable place to begin. + useEffect(() => { + if (!enabled || !isGeomWalk) return; + const entries = rebuildGeoms(); + setGeomIndex(0); + if (entries.length > 0) { + focusedOnce.current = true; + void goGeom(0, entries); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, isGeomWalk, geomOrder]); + + // A scene change (file loaded/unloaded) rebuilds the list but keeps the + // current position; focus the first geom if the scene only just populated. + useEffect(() => { + if (!enabled || !isGeomWalk) return; + const entries = rebuildGeoms(); + setGeomIndex((i) => Math.min(i, Math.max(0, entries.length - 1))); + if (!focusedOnce.current && entries.length > 0) { + focusedOnce.current = true; + void goGeom(0, entries); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadedSources]); + + // Re-apply isolation when the hide-unselected toggle flips mid-walk. + useEffect(() => { + if (!enabled || !isGeomWalk || geomEntries.length === 0) return; + void goGeom(geomIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hideUnselected]); + + // Switching scope disposes the current scope's meshes; drop the walk's stale entries + any + // isolation/selection so nothing references a freed mesh. The scene-change effect above then + // rebuilds cleanly once the new scope's geoms land. + useEffect(() => { + endGeomWalk(); + setGeomEntries([]); + setGeomIndex(0); + focusedOnce.current = false; + }, [scopeKey]); + + // Leaving a geom walk (switch to files, disable gallery) restores the scene — + // but only if we actually entered one, so enabling the files walk never + // clears a selection the user made before opening the gallery. + useEffect(() => { + if ((!enabled || !isGeomWalk) && focusedOnce.current) { + focusedOnce.current = false; + endGeomWalk(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, isGeomWalk]); + + // ---- Shared nav ------------------------------------------------------- + const step = useCallback( + (delta: number) => { + if (isGeomWalk) void goGeom(geomIndex + delta); + else void go(index + delta); + }, + [isGeomWalk, goGeom, geomIndex, go, index], + ); + + // Left/Right arrows cycle prev/next while gallery mode is active (the camera + // controls don't bind arrow keys, so there's no conflict). Ignored while + // typing or while a file load is in flight; preventDefault stops scrolling. + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + if (!isGeomWalk && useLoadQueueStore.getState().current) return; // a load is already running + e.preventDefault(); + step(e.key === "ArrowRight" ? 1 : -1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [enabled, step, isGeomWalk]); + + // ---- Tools drawer (re-convert; FILES walk only) ----------------------- + const [toolsOpen, setToolsOpen] = useState(false); + const [reconverting, setReconverting] = useState(false); + const [reconvertMsg, setReconvertMsg] = useState(null); + // Serializer/tessellator pick for the reconvert. Empty until the user + // touches a dropdown; convertWithSelection then normalises against the + // backend matrix (defaults to the cpp/native server path). + const [serializerSel, setSerializerSel] = useState({}); + + const current = files[index] ?? null; + const currentExt = current ? (current.slice(current.lastIndexOf(".")).toLowerCase()) : ""; + + const reconvert = useCallback(async () => { + if (!current || reconverting) return; + const scope = useScopeStore.getState().current; + const scopePart = scope ? scopeUrlPart(scope) : ""; + setReconverting(true); + setReconvertMsg("re-converting…"); + try { + const derivedKey = await convertWithSelection(scopePart, current, "glb", { + selection: serializerSel, + reconvert: true, + }); + await clear_loaded_model(); + const {overlay_file_in_scene} = await import("@/utils/scene/handlers/overlay_file_in_scene"); + await overlay_file_in_scene(current, derivedKey, {scope: scopePart}); + setReconvertMsg("loaded fresh conversion"); + } catch (e) { + setReconvertMsg(`failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setReconverting(false); + } + }, [current, reconverting, serializerSel]); + + // NOTE: every hook must be declared before the `if (!enabled) return null` early return below — + // React counts hooks per render, so a hook after the return changes the count when the gallery is + // toggled on/off and crashes the app ("rendered more hooks than during the previous render"). + const [nameCopied, setNameCopied] = useState(false); + const copyName = useCallback(async (text: string) => { + if (!text) return; + if (await writeToClipboard(text)) { + setNameCopied(true); + window.setTimeout(() => setNameCopied(false), 1200); + } + }, []); + + // Clicking the "N / M" counter opens a single-file picker (the shared storage-browser tree) to jump + // straight to a file instead of stepping Prev/Next through a long scope. The picker returns a blob + // key; map it back to the gallery's file list (by exact name or basename) and load it via `go` — + // which works in both walks (in the geoms walk, loading a new file rebuilds the geom list). + const [filePickerOpen, setFilePickerOpen] = useState(false); + const jumpToFile = useCallback( + (key: string) => { + setFilePickerOpen(false); + const base = key.split("/").pop() ?? key; + let at = files.indexOf(key); + if (at < 0) at = files.findIndex((n) => (n.split("/").pop() ?? n) === base); + if (at >= 0) void go(at); + }, + [files, go], + ); + + if (!enabled) return null; + + const count = isGeomWalk ? geomEntries.length : files.length; + const activeIndex = isGeomWalk ? geomIndex : index; + const empty = count === 0; + const navBusy = !isGeomWalk && !!loadBusy; + const curEntry = isGeomWalk ? geomEntries[geomIndex] : undefined; + const spikeSuffix = + geomOrder === "distorted" && curEntry + ? ` · spike ${curEntry.spike.toFixed(1)}× · ${curEntry.spikeTris} tri${curEntry.spikeTris === 1 ? "" : "s"}` + : ""; + const primaryLabel = isGeomWalk + ? empty + ? geomOrder === "distorted" + ? "no distorted geoms 🎉" + : "no geoms in scene" + : (selectedName ?? `geom ${geomIndex + 1}`) + spikeSuffix + : empty + ? "no viewable files in this scope" + : (current ?? ""); + + const walkSelector = ( + + ); + + // Geom-walk sub-controls: order + isolate toggle + refresh. + const geomControls = isGeomWalk && ( +
    + + + +
    + ); + + // Re-convert operates on the current file (files[index]), which is the loaded file in BOTH the + // files walk and the geoms walk — so the reconvert-path tools are available in either mode (a user + // walking a model's geoms can still reconvert it via a different path, e.g. to pick up a fix). + const toolsDrawer = ( +
    + + {toolsOpen && ( +
    + {current && ( +
    + +
    + )} + + {reconvertMsg && {reconvertMsg}} +
    + )} +
    + ); + + return ( + <> + {/* Desktop: compact top-right card. */} +
    +
    + {walkSelector} + + + +
    + {geomControls} + + {toolsDrawer} +
    + + {/* Mobile: bottom bar, larger tap targets, clear of the top menu. */} +
    +
    + {walkSelector} + +
    + + +
    + +
    + {geomControls} + {toolsDrawer} +
    + + isStreamingFEAResult(f.key) || canLoadIntoSceneLegacy(f.key)} + onCancel={() => setFilePickerOpen(false)} + onPick={jumpToFile} + /> + + ); +}; + +export default GalleryControls; diff --git a/src/frontend/src/components/viewer/sceneHelpers/adaptiveClipping.ts b/src/frontend/src/components/viewer/sceneHelpers/adaptiveClipping.ts new file mode 100644 index 000000000..4a7cbdc99 --- /dev/null +++ b/src/frontend/src/components/viewer/sceneHelpers/adaptiveClipping.ts @@ -0,0 +1,44 @@ +import * as THREE from "three"; +import CameraControls from "camera-controls"; +import {OrbitControls} from "three/examples/jsm/controls/OrbitControls.js"; + +// Scale the camera near/far clipping planes (and the control dolly limits) to the size of the +// content the camera is framing, so small models don't clip away when zooming in. +// +// The camera is constructed with a fixed near = 0.1 (setupCamera.ts). That means you can never get +// closer than 0.1 world units before geometry crosses the near plane and disappears — fine for a +// building-scale model, but for a model only a few units across (e.g. a component in mm modelled at +// unit scale) 0.1 clips most of it the moment you try to zoom in. Deriving near from the framed +// bounding-sphere radius makes the zoom-in headroom proportional to model size at any scale. +// +// near/far span keeps a ~1e6 ratio; with far tied to the same radius the model stays comfortably +// inside the frustum (camera fits at ~2*radius) while near shrinks enough to inspect fine detail. +export function applyAdaptiveClipping( + camera: THREE.PerspectiveCamera, + controls: OrbitControls | CameraControls | null | undefined, + radius: number, +) { + if (!isFinite(radius) || radius <= 0) return; + + const near = Math.max(radius * 1e-3, 1e-6); + const far = Math.max(radius * 1e3, near * 1e4); + + // Only rewrite when meaningfully different — avoids redundant projection-matrix churn when the + // same model is re-framed (fit-all, center-on-selection) at an already-correct scale. + if (Math.abs(camera.near - near) > near * 1e-3 || Math.abs(camera.far - far) > far * 1e-3) { + camera.near = near; + camera.far = far; + camera.updateProjectionMatrix(); + } + + // Let the controls dolly all the way in to the near plane (and not past far). CameraControls and + // OrbitControls both gate dolly by minDistance/maxDistance; default minDistance keeps you further + // out than the new near plane would allow, so match them to the adaptive planes. + if (controls instanceof OrbitControls) { + controls.minDistance = near; + controls.maxDistance = far; + } else if (controls instanceof CameraControls) { + controls.minDistance = near; + controls.maxDistance = far; + } +} diff --git a/src/frontend/src/components/viewer/sceneHelpers/setupCameraControlsHandlers.ts b/src/frontend/src/components/viewer/sceneHelpers/setupCameraControlsHandlers.ts index 00d95f62a..2c4b6f47d 100644 --- a/src/frontend/src/components/viewer/sceneHelpers/setupCameraControlsHandlers.ts +++ b/src/frontend/src/components/viewer/sceneHelpers/setupCameraControlsHandlers.ts @@ -8,6 +8,7 @@ import {useTreeViewStore} from "@/state/treeViewStore"; import CameraControls from "camera-controls"; import {copySelectionNames} from "@/utils/clipboard/copySelectionNames"; import {hideSelectedRanges, unhideAllRanges} from "@/utils/scene/visibility"; +import {applyAdaptiveClipping} from "@/components/viewer/sceneHelpers/adaptiveClipping"; export function setupCameraControlsHandlers( scene: THREE.Scene, @@ -142,6 +143,9 @@ export const zoomToAll = (scene: THREE.Scene, camera: THREE.PerspectiveCamera, c const center = sphere.center.clone(); const radius = sphere.radius; + // Adapt near/far to model size so small models can be zoomed into without near-plane clipping. + applyAdaptiveClipping(camera, controls, radius); + // Compute required distance so the sphere fits both vertically and horizontally const vFov = THREE.MathUtils.degToRad(camera.fov); const aspect = camera.aspect || 1; diff --git a/src/frontend/src/components/viewer/sceneHelpers/setupModelLoader.ts b/src/frontend/src/components/viewer/sceneHelpers/setupModelLoader.ts index bfb58dddd..a4881f2ff 100644 --- a/src/frontend/src/components/viewer/sceneHelpers/setupModelLoader.ts +++ b/src/frontend/src/components/viewer/sceneHelpers/setupModelLoader.ts @@ -3,7 +3,8 @@ import {prepareLoadedModel} from "./prepareLoadedModel"; import {useModelState} from "@/state/modelState"; import {useOptionsStore} from "@/state/optionsStore"; import {useAnimationStore} from "@/state/animationStore"; -import {animationControllerRef, modelKeyMapRef, sceneRef, simulationDataRef, adaExtensionRef} from "@/state/refs"; +import {animationControllerRef, cameraRef, controlsRef, modelKeyMapRef, sceneRef, simulationDataRef, adaExtensionRef} from "@/state/refs"; +import {zoomToAll} from "./setupCameraControlsHandlers"; import {SimulationDataExtensionMetadata} from "@/extensions/design_and_analysis_extension"; import {requestRender} from "@/state/perfStore"; import {FilePurpose} from "@/flatbuffers/base/file-purpose"; @@ -14,6 +15,7 @@ import {AnimationController} from "@/utils/scene/animations/AnimationController" import {updateAllPointsSize} from "@/utils/scene/updatePointSizes"; import type {LoadMetricsRecorder} from "@/utils/scene/loadMetrics"; import {fastSceneBox} from "@/utils/scene/boundsFast"; +import {applyAdaptiveClipping} from "@/components/viewer/sceneHelpers/adaptiveClipping"; /** Optional hook to mutate the freshly-loaded gltf scene (typically * to inject ``userData["draw_ranges_"]`` and @@ -173,5 +175,30 @@ export async function setupModelLoaderAsync( // Set the hasAnimation flag to true in the store animationStore.setHasAnimation(true); } + + // Adapt near/far clipping to the loaded model's size so small models can be zoomed into without + // near-plane clipping — independent of autoFit (zoomToAll re-applies it, but this covers the + // autoFit-off case too). Uses the model bounding box captured above / in the store. + { + const cam = cameraRef.current; + const ctl = controlsRef.current; + const box = useModelState.getState().boundingBox; // fresh — setBoundingBox ran above + if (cam && box && !box.isEmpty()) { + const radius = box.getBoundingSphere(new THREE.Sphere()).radius; + applyAdaptiveClipping(cam as THREE.PerspectiveCamera, ctl, radius); + } + } + + // Auto fit-to-all after the model is in the scene (scene-config toggle, default on) — + // frames a freshly loaded model, and each geom cycled through in gallery mode, without a + // manual Shift+A. Deferred a frame so the just-added meshes' world bounds are current. + if (optionsStore.autoFit) { + const cam = cameraRef.current; + const ctl = controlsRef.current; + const scn = sceneRef.current; + if (cam && ctl && scn) { + requestAnimationFrame(() => zoomToAll(scn, cam as THREE.PerspectiveCamera, ctl)); + } + } return modelGroup; } diff --git a/src/frontend/src/components/viewer/sceneHelpers/setupPointerHandler.ts b/src/frontend/src/components/viewer/sceneHelpers/setupPointerHandler.ts index 342dff3f5..f40be7ddf 100644 --- a/src/frontend/src/components/viewer/sceneHelpers/setupPointerHandler.ts +++ b/src/frontend/src/components/viewer/sceneHelpers/setupPointerHandler.ts @@ -3,6 +3,7 @@ import * as THREE from "three"; import {handleClickEmptySpace} from "@/utils/mesh_select/handleClickEmptySpace"; import {handleClickMesh} from "@/utils/mesh_select/handleClickMesh"; import {handleClickPoints} from "@/utils/mesh_select/handleClickPoints"; +import {CustomBatchedMesh} from "@/utils/mesh_select/CustomBatchedMesh"; // handleClickMesh signature change accommodates a prefilledRangeId // from the GPU mesh picker; the raycast fallback still passes only // the intersection. @@ -109,9 +110,18 @@ export function setupPointerHandler( try { const meshPick = gpuMeshPicker.pickAt(e.clientX, e.clientY); if (meshPick) { + // GPU picking identifies the object + a representative vertex (the draw range's + // first vertex), NOT the exact clicked point — so "Clicked at" would show the same + // coordinate for every click on one object (very noticeable on a small model). Refine + // it with a raycast against just the picked mesh; that also yields the exact triangle + // index for face-level picking (the full-scene raycast would hit the edge overlay first + // and require zooming way in). Falls back to the vertex when the mesh is too large to + // raycast cheaply (keeps the huge-FEA fast path — face picking then unavailable there). + const refined = raycastPointOnMesh(e, camera, renderer, meshPick.mesh); const fakeIntersection: THREE.Intersection = { object: meshPick.mesh, - point: meshPick.worldPosition.clone(), + point: refined?.point ?? meshPick.worldPosition.clone(), + faceIndex: refined?.faceIndex, distance: 0, } as THREE.Intersection; await handleClickMesh(fakeIntersection, e, meshPick.rangeId); @@ -149,8 +159,14 @@ export function setupPointerHandler( } if (nearestMesh) { - // Clear any highlighted point if not multi-select - await handleClickMesh(nearestMesh, e); + // Only imported geometry (CustomBatchedMesh) is selectable. If the first opaque + // hit is anything else — a section-plane cap, a helper, a non-shape mesh — treat + // the click as a deselect, not a no-op: clicking off a shape clears the selection. + if (nearestMesh.object instanceof CustomBatchedMesh) { + await handleClickMesh(nearestMesh, e); + } else { + handleClickEmptySpace(e); + } return; } @@ -182,6 +198,39 @@ export function setupPointerHandler( * landed essentially at the camera position and rotation degenerated * into spinning in place — easiest to trigger on mobile, where you * pinch in close before double-tapping. */ +// Raycast the clicked pixel against a SINGLE mesh to recover the exact world-space hit point. +// Used to refine the GPU picker's representative-vertex position into the real clicked point. +// Bounded by triangle count so a whole-model batched mesh (millions of tris) doesn't stall the +// click — above the cap the caller keeps the fast approximate vertex. +const MAX_REFINE_TRIS = 600_000; + +function raycastPointOnMesh( + e: MouseEvent, + camera: THREE.PerspectiveCamera, + renderer: THREE.WebGLRenderer, + mesh: THREE.Object3D, +): {point: THREE.Vector3; faceIndex: number | undefined} | null { + try { + const geom = (mesh as THREE.Mesh).geometry as THREE.BufferGeometry | undefined; + if (!geom) return null; + const triCount = (geom.index ? geom.index.count : geom.attributes.position?.count ?? 0) / 3; + if (triCount > MAX_REFINE_TRIS) return null; + const rect = renderer.domElement.getBoundingClientRect(); + const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const ny = -((e.clientY - rect.top) / rect.height) * 2 + 1; + const ray = new THREE.Raycaster(); + ray.layers.set(0); + ray.layers.disable(1); + ray.setFromCamera(new THREE.Vector2(nx, ny), camera); + // Only the picked mesh — never the edge/wireframe overlays — so the triangle index is reliable + // regardless of zoom (the full-scene raycast would hit an edge line first when zoomed out). + const hit = ray.intersectObject(mesh, false)[0]; + return hit?.point ? {point: hit.point.clone(), faceIndex: hit.faceIndex ?? undefined} : null; + } catch { + return null; + } +} + function pickWorldPoint( e: MouseEvent, camera: THREE.PerspectiveCamera, diff --git a/src/frontend/src/index.html b/src/frontend/src/index.html index 2e7097401..e6ccd915e 100644 --- a/src/frontend/src/index.html +++ b/src/frontend/src/index.html @@ -12,6 +12,18 @@ Hosted viewer mode injects window.COMMS_MODE / window.API_BASE via /config.js. In Vite dev or embedded mode the request 404s and the comms picker falls back to the WS default. + + MUST stay a blocking (non-defer) classic script. Vite emits the + SPA entry as a module in ( diff --git a/src/frontend/src/runtime/config.ts b/src/frontend/src/runtime/config.ts index 9821abd6e..623ffbb80 100644 --- a/src/frontend/src/runtime/config.ts +++ b/src/frontend/src/runtime/config.ts @@ -10,6 +10,26 @@ export interface ConversionOption { default?: boolean | string | number | null; description?: string; enum?: readonly string[]; + // Data-driven extras carried by dependent enum options (serializer / + // tessellator dropdowns). The backend is the single source of this + // vocabulary; the frontend renders straight from these fields and never + // hardcodes the choices: + // labels — human label per enum value. + // depends_on — name of the option this one's choices depend on. + // enum_by — for each value of the depends_on option, the subset of + // THIS option's enum that applies (drives the dependent + // dropdown; empty/absent = static enum). + // runtime — per enum value, "client" (runs in the browser: WASM / + // Pyodide) vs "server" (worker). Lets the SPA route + gate. + labels?: Readonly>; + depends_on?: string; + enum_by?: Readonly>; + runtime?: Readonly>; + // Human display name for the axis this option represents (distinct from the + // per-value `labels`). Lets the backend name the 2nd path-dropdown per target + // — "Tessellator" for mesh targets, "Writer" for B-rep targets — without the + // frontend hardcoding it. + title?: string; } // diff --git a/src/frontend/src/services/auth/oidc.ts b/src/frontend/src/services/auth/oidc.ts index 5fc97f04a..774815ff6 100644 --- a/src/frontend/src/services/auth/oidc.ts +++ b/src/frontend/src/services/auth/oidc.ts @@ -33,6 +33,14 @@ const STORAGE_PKCE = "ada-oidc-pkce"; const STORAGE_RETURN = "ada-oidc-return"; const STORAGE_REFRESH = "ada-oidc-refresh"; const STORAGE_STATE = "ada-oidc-state"; +// Persistent cache of the OIDC discovery doc. The endpoints (authorize/token/ +// jwks) are public and effectively static, so caching across page reloads +// removes a ~260ms authentik round-trip (plus a possible slow TLS handshake) +// from the token-refresh path that runs on every load. Keyed by issuer so a +// config change invalidates it; short-ish TTL so a genuine endpoint move is +// picked up within the day. +const STORAGE_DISCOVERY = "ada-oidc-discovery"; +const DISCOVERY_TTL_MS = 24 * 60 * 60 * 1000; let discovery: DiscoveryDoc | null = null; let accessToken: string | null = null; @@ -70,9 +78,29 @@ async function loadDiscovery(): Promise { if (discovery) return discovery; const issuer = runtime.authIssuer(); if (!issuer) throw new Error("AUTH_ISSUER not configured"); + // Persistent cache (survives reloads), validated against the current issuer + // and TTL. Wrapped in try/catch so a disabled/full localStorage (private + // mode, embedded iframe) silently falls back to fetching. + try { + const raw = localStorage.getItem(STORAGE_DISCOVERY); + if (raw) { + const c = JSON.parse(raw) as {doc: DiscoveryDoc; at: number; issuer: string}; + if (c.doc && c.issuer === issuer && Date.now() - c.at < DISCOVERY_TTL_MS) { + discovery = c.doc; + return discovery; + } + } + } catch { + /* corrupt / unavailable cache — fall through and refetch */ + } const r = await fetch(`${issuer}/.well-known/openid-configuration`); if (!r.ok) throw new Error(`oidc discovery failed: ${r.status}`); discovery = await r.json(); + try { + localStorage.setItem(STORAGE_DISCOVERY, JSON.stringify({doc: discovery, at: Date.now(), issuer})); + } catch { + /* storage unavailable — the in-memory module cache still applies */ + } return discovery!; } diff --git a/src/frontend/src/services/conversion/index.ts b/src/frontend/src/services/conversion/index.ts index 060127681..422c8481d 100644 --- a/src/frontend/src/services/conversion/index.ts +++ b/src/frontend/src/services/conversion/index.ts @@ -7,6 +7,7 @@ import {runtime} from "@/runtime/config"; import {viewerApi, TargetFormat, ScopeUrl} from "@/services/viewerApi"; import {convertViaServer} from "./serverPipeline"; import {wasmSupportsConversion, isWasmFeaSource} from "./wasmSupport"; +import {serializerSchemaFor, normalizeSelection, SerializerSelection} from "./serializerMatrix"; // NOTE: the Pyodide pipeline is imported lazily (inside ensureConverted), not // at module top. It pulls a `new Worker(new URL(...))` that Vite emits as a @@ -63,6 +64,83 @@ export async function ensureConverted( return convertViaServer(scope, sourceKey, targetFormat, opts); } +function extOf(name: string): string { + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i).toLowerCase(); +} + +/** + * Convert routing the serializer/tessellator selection (from the reconvert + * dropdowns) to the right pipeline: a client serializer (runtime="client", + * i.e. the in-browser WASM/Pyodide path) goes to the Pyodide pipeline, any + * server serializer forwards ``{serializer, tessellator}`` as conversion + * options to the worker where :func:`_apply_glb_serializer` folds them into the + * engine knobs. Returns the derived storage key. Shared by the gallery + * reconvert tool and the /convert page so both honour the same matrix. + */ +export async function convertWithSelection( + scope: ScopeUrl, + sourceKey: string, + targetFormat: TargetFormat, + opts?: { + selection?: SerializerSelection; + extraOptions?: Record; + reconvert?: boolean; + }, +): Promise { + const ext = extOf(sourceKey); + const schema = serializerSchemaFor(ext, targetFormat); + const selection = opts?.selection ?? {}; + const resolved = schema ? normalizeSelection(schema, selection) : null; + + if (resolved?.isClient) { + // In-browser path — no server round-trip. Two client engines, picked by + // the tessellator token: + // wasm-native → the OCC-free, no-pyodide adacpp_step_glb embind module + // (STEP → GLB only, 2.3 MB, no Python runtime). + // pyodide → the Pyodide pipeline (ifc / step / mesh, wasm wheels). + if (resolved.tessellator === "wasm-native") { + // CAD→GLB (step/ifc → glb) via the native embind GLB modules. + const {convertViaWasmNativeAndUpload, nativeCadGlbSupported} = await import( + "./nativeCadGlbPipeline" + ); + if (nativeCadGlbSupported(sourceKey, targetFormat)) { + return convertViaWasmNativeAndUpload(scope, sourceKey, targetFormat); + } + // B-rep→B-rep (step→ifc / ifc→step) via the native embind writer module. + const {convertViaWasmBrepAndUpload, nativeBrepWriterSupported} = await import( + "./nativeBrepWriterPipeline" + ); + if (nativeBrepWriterSupported(sourceKey, targetFormat)) { + return convertViaWasmBrepAndUpload(scope, sourceKey, targetFormat); + } + // No native module covers this (source, target) — fall through to Pyodide. + } + if (!wasmSupportsConversion(sourceKey, targetFormat)) { + throw new Error( + `in-browser conversion doesn't support ${ext || sourceKey} → ${targetFormat}`, + ); + } + const {convertViaPyodideAndUpload} = await import("./pyodidePipeline"); + return convertViaPyodideAndUpload(scope, sourceKey, targetFormat); + } + + if (!runtime.convertEnabled()) { + throw new Error("conversion not enabled on this deployment"); + } + const conversionOptions: Record = { + ...(opts?.extraOptions ?? {}), + }; + if (resolved) { + conversionOptions.serializer = resolved.serializer; + conversionOptions.tessellator = resolved.tessellator; + } + return convertViaServer(scope, sourceKey, targetFormat, { + conversionOptions: Object.keys(conversionOptions).length ? conversionOptions : undefined, + reconvert: opts?.reconvert, + }); +} + // Backwards-compatible wrapper for the GLB-for-viewing flow. ``opts.streamer`` // forces the memory-bounded streaming STEP->GLB converter (for large assemblies // that OOM the normal OCC path). diff --git a/src/frontend/src/services/conversion/nativeBrepWriterPipeline.ts b/src/frontend/src/services/conversion/nativeBrepWriterPipeline.ts new file mode 100644 index 000000000..c89e71556 --- /dev/null +++ b/src/frontend/src/services/conversion/nativeBrepWriterPipeline.ts @@ -0,0 +1,164 @@ +// In-browser conversion pipeline using the NATIVE (no-pyodide) adacpp_brep_writer wasm module. +// STEP→IFC and IFC→STEP. Fetches source bytes, runs the embind writer in a Web Worker, PUTs the +// output back to storage (same derived-key contract as the server + pyodide paths), and records a +// metrics-rich audit row (WASM badge). Buffered MEMFS IO; an OPFS-streaming tier can follow the +// CAD→GLB pattern for very large B-rep files. + +import {useConversionStore, ConversionJob} from "@/state/conversionStore"; +import {viewerApi, ScopeUrl, TargetFormat} from "@/services/viewerApi"; +import { + nativeBrepWrite, + nativeBrepWriteStreaming, + nativeBrepWriterOpfsAvailable, + type BrepDir, +} from "@/utils/nativeConvert/brepWriterConverter"; + +const WASM_IMAGE_TAG = "wasm:native-brepwriter"; + +// Above this source size, prefer the OPFS-streaming tier (source read off-disk, bounded RSS) when +// OPFS + a presigned URL are available; below it, the buffered MEMFS path. +const OPFS_STREAM_THRESHOLD = 100 * 1024 * 1024; // 100 MB + +function extOf(name: string): string { + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i).toLowerCase(); +} + +// The conversion direction for a (source ext, target). STEP/STP→IFC and IFC→STEP only. +function brepDirFor(sourceKey: string, targetFormat: TargetFormat): BrepDir | null { + const ext = extOf(sourceKey); + if ((ext === ".step" || ext === ".stp") && targetFormat === "ifc") return "step2ifc"; + if (ext === ".ifc" && targetFormat === "step") return "ifc2step"; + return null; +} + +/** Does the native B-rep writer handle this (source, target)? STEP→IFC / IFC→STEP only. */ +export function nativeBrepWriterSupported(sourceKey: string, targetFormat: TargetFormat): boolean { + return brepDirFor(sourceKey, targetFormat) !== null; +} + +export async function convertViaWasmBrepAndUpload( + scope: ScopeUrl, + sourceKey: string, + targetFormat: TargetFormat, + opts?: {auditRunId?: string | null}, +): Promise { + const dir = brepDirFor(sourceKey, targetFormat); + if (!dir) { + throw new Error(`native B-rep writer only does STEP→IFC / IFC→STEP (got ${sourceKey} → ${targetFormat})`); + } + const storeKey = `${sourceKey}::${targetFormat}`; + const store = useConversionStore.getState(); + const startedAt = Date.now(); + const job: ConversionJob = { + sourceKey: storeKey, + jobId: "wasm-native", + derivedKey: "", + status: "running", + progress: 0.05, + stage: "fetching source", + error: null, + startedAt, + }; + store.setJob(storeKey, job); + + let auditJobId: string | null = null; + try { + auditJobId = await viewerApi.auditLocalCreate(scope, { + key: sourceKey, + target_format: targetFormat, + audit_run_id: opts?.auditRunId ?? null, + image_tag: WASM_IMAGE_TAG, + }); + } catch { + /* proceed without an audit row */ + } + const finishAudit = async (body: Parameters[2]) => { + if (!auditJobId) return; + try { + await viewerApi.auditLocalUpdate(scope, auditJobId, body); + } catch { + /* best-effort */ + } + }; + + try { + const label = dir === "step2ifc" ? "STEP → IFC" : "IFC → STEP"; + // Prefer OPFS streaming for large sources (bounded RSS): presign + feature-detect OPFS. Falls + // back to buffering the whole source into the wasm heap when small, presign-less, or no OPFS. + // A streaming run is NOT retried buffered (re-reading a huge file would OOM). + let streamUrl: {url: string; size: number} | null = null; + try { + const dl = await viewerApi.requestDownloadUrl(scope, sourceKey); + if (dl.size >= OPFS_STREAM_THRESHOLD && (await nativeBrepWriterOpfsAvailable())) { + streamUrl = {url: dl.url, size: dl.size}; + } + } catch { + streamUrl = null; // presign unavailable → buffered fallback + } + + let output: ArrayBuffer; + let products: number; + let ms: number; + let readBytes: number; + if (streamUrl) { + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.2, + stage: `writing ${label} in browser (native, OPFS)`, + }); + ({output, products, ms} = await nativeBrepWriteStreaming(dir, streamUrl.url)); + readBytes = streamUrl.size; // source size; only streamed, never staged whole + } else { + const sourceBuf = await viewerApi.getBlob(scope, sourceKey); + readBytes = sourceBuf.byteLength; // capture before the buffer is transferred to the worker + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.2, + stage: `writing ${label} in browser (native)`, + }); + + ({output, products, ms} = await nativeBrepWrite(dir, sourceBuf)); + } + const outBytes = new Uint8Array(output); + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.9, + stage: `uploading derived (${products.toLocaleString()} products, ${Math.round(ms)} ms)`, + }); + + const derivedKey = await viewerApi.putDerivedBlob(scope, sourceKey, targetFormat, outBytes); + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 1.0, + stage: "ready", + status: "done", + derivedKey, + }); + + await finishAudit({ + status: "done", + duration_ms: Date.now() - startedAt, + read_bytes: readBytes, + write_bytes: outBytes.byteLength, + }); + return derivedKey; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + status: "error", + stage: "error", + error: msg, + }); + await finishAudit({ + status: "error", + duration_ms: Date.now() - startedAt, + error: msg, + }); + throw err; + } +} diff --git a/src/frontend/src/services/conversion/nativeCadGlbPipeline.ts b/src/frontend/src/services/conversion/nativeCadGlbPipeline.ts new file mode 100644 index 000000000..18b4805bf --- /dev/null +++ b/src/frontend/src/services/conversion/nativeCadGlbPipeline.ts @@ -0,0 +1,169 @@ +// In-browser conversion pipeline using the NATIVE (no-pyodide) adacpp CAD→GLB wasm modules +// (adacpp_step_glb / adacpp_ifc_glb). STEP/STP + IFC → GLB. Fetches source bytes from storage, runs +// the embind module in a Web Worker, PUTs the resulting GLB back to storage (same derived-key +// contract as the server + pyodide paths), and records a metrics-rich audit row so the conversion +// shows up in the audit panel with a "WASM" badge exactly like the pyodide path. + +import {useConversionStore, ConversionJob} from "@/state/conversionStore"; +import {viewerApi, ScopeUrl, TargetFormat} from "@/services/viewerApi"; +import { + nativeCadToGlb, + nativeCadToGlbStreaming, + nativeCadGlbOpfsAvailable, + type CadKind, +} from "@/utils/nativeConvert/cadGlbConverter"; + +// Distinguishes the native embind module from the pyodide path in the audit panel. +const WASM_IMAGE_TAG = "wasm:native-cadglb"; + +// Above this source size we prefer the OPFS-streaming tier (source read off-disk via pread, bounded +// RSS) over buffering the whole source into the wasm heap — provided OPFS + a presigned URL are both +// available. Below it, the buffered MEMFS path is simpler and plenty (and the validated default). +const OPFS_STREAM_THRESHOLD = 100 * 1024 * 1024; // 100 MB + +function extOf(name: string): string { + const i = name.lastIndexOf("."); + return i === -1 ? "" : name.slice(i).toLowerCase(); +} + +// Which native module handles a source extension (STEP/STP → step_glb, IFC → ifc_glb). +function cadKindFor(sourceKey: string): CadKind | null { + const ext = extOf(sourceKey); + if (ext === ".step" || ext === ".stp") return "step"; + if (ext === ".ifc") return "ifc"; + return null; +} + +/** Does a native (no-pyodide) module handle this (source, target)? {STEP,STP,IFC} → GLB. */ +export function nativeCadGlbSupported(sourceKey: string, targetFormat: TargetFormat): boolean { + return targetFormat === "glb" && cadKindFor(sourceKey) !== null; +} + +export async function convertViaWasmNativeAndUpload( + scope: ScopeUrl, + sourceKey: string, + targetFormat: TargetFormat = "glb", + opts?: {auditRunId?: string | null}, +): Promise { + const kind = cadKindFor(sourceKey); + if (targetFormat !== "glb" || !kind) { + throw new Error(`native wasm module only converts STEP/IFC → GLB (got ${sourceKey} → ${targetFormat})`); + } + const storeKey = `${sourceKey}::${targetFormat}`; + const store = useConversionStore.getState(); + const startedAt = Date.now(); + const job: ConversionJob = { + sourceKey: storeKey, + jobId: "wasm-native", + derivedKey: "", + status: "running", + progress: 0.05, + stage: "fetching source", + error: null, + startedAt, + }; + store.setJob(storeKey, job); + + // Open the audit row first so the conversion shows as "running". Best-effort. + let auditJobId: string | null = null; + try { + auditJobId = await viewerApi.auditLocalCreate(scope, { + key: sourceKey, + target_format: targetFormat, + audit_run_id: opts?.auditRunId ?? null, + image_tag: WASM_IMAGE_TAG, + }); + } catch { + /* proceed without an audit row */ + } + const finishAudit = async (body: Parameters[2]) => { + if (!auditJobId) return; + try { + await viewerApi.auditLocalUpdate(scope, auditJobId, body); + } catch { + /* best-effort */ + } + }; + + try { + // Prefer the OPFS-streaming tier for large sources: mint a presigned URL, feature-detect + // OPFS, and stream the STEP through OPFS (bounded RSS) so a multi-GB deck never has to fit + // the wasm heap. Falls back to buffering the whole source into the wasm heap when the source + // is small, presign is unavailable (local-disk backends 503), or OPFS isn't supported. A + // streaming run is NOT retried buffered — re-reading a huge deck into the heap would just OOM. + const upper = kind.toUpperCase(); + let streamUrl: {url: string; size: number} | null = null; + try { + const dl = await viewerApi.requestDownloadUrl(scope, sourceKey); + if (dl.size >= OPFS_STREAM_THRESHOLD && (await nativeCadGlbOpfsAvailable(kind))) { + streamUrl = {url: dl.url, size: dl.size}; + } + } catch { + streamUrl = null; // presign unavailable → buffered fallback + } + + let glb: ArrayBuffer; + let products: number; + let ms: number; + let readBytes: number; + if (streamUrl) { + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.15, + stage: `streaming ${upper} → GLB in browser (native, OPFS)`, + }); + ({glb, products, ms} = await nativeCadToGlbStreaming(kind, streamUrl.url)); + readBytes = streamUrl.size; // source size; only streamed, never staged whole + } else { + const sourceBuf = await viewerApi.getBlob(scope, sourceKey); + readBytes = sourceBuf.byteLength; // capture before the buffer is transferred to the worker + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.15, + stage: `converting ${upper} → GLB in browser (native)`, + }); + + ({glb, products, ms} = await nativeCadToGlb(kind, sourceBuf)); + } + const outBytes = new Uint8Array(glb); + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 0.9, + stage: `uploading derived (${products.toLocaleString()} products, ${Math.round(ms)} ms)`, + }); + + const derivedKey = await viewerApi.putDerivedBlob(scope, sourceKey, targetFormat, outBytes); + + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + progress: 1.0, + stage: "ready", + status: "done", + derivedKey, + }); + + await finishAudit({ + status: "done", + duration_ms: Date.now() - startedAt, + read_bytes: readBytes, + write_bytes: outBytes.byteLength, + }); + return derivedKey; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + store.setJob(storeKey, { + ...(store.jobs[storeKey] || job), + status: "error", + stage: "error", + error: msg, + }); + await finishAudit({ + status: "error", + duration_ms: Date.now() - startedAt, + error: msg, + }); + throw err; + } +} diff --git a/src/frontend/src/services/conversion/serializerMatrix.ts b/src/frontend/src/services/conversion/serializerMatrix.ts new file mode 100644 index 000000000..2f406e0d0 --- /dev/null +++ b/src/frontend/src/services/conversion/serializerMatrix.ts @@ -0,0 +1,65 @@ +// Frontend adapter over the backend-advertised serializer × tessellator +// matrix. The vocabulary, labels, per-serializer tessellator sets and the +// client-vs-server runtime split are ALL declared by the adapy backend and +// ride to the SPA on the conversion matrix (`options[]`, see +// converter.py `_glb_serializer_options`). This module reads that schema — +// it hardcodes NONE of the choices — so the two dependent reconvert dropdowns +// (gallery tools + ConversionRow) and the client/server routing all agree. + +import {runtime, ConversionOption} from "@/runtime/config"; + +export interface SerializerSelection { + serializer?: string; + tessellator?: string; +} + +export interface SerializerSchema { + serializer: ConversionOption; // enum with `labels` + `runtime` + tessellator: ConversionOption; // enum with `labels` + `enum_by` (keyed by serializer) +} + +/** The serializer + tessellator option schema for a (source ext, target) + * pair, or null when the target advertises no serializer choice (only →GLB + * rows do). */ +export function serializerSchemaFor(ext: string, target: string): SerializerSchema | null { + const opts = runtime.conversionOptionsFor(ext, target); + const serializer = opts.find((o) => o.name === "serializer"); + const tessellator = opts.find((o) => o.name === "tessellator"); + if (!serializer || !tessellator || !serializer.enum || !tessellator.enum) return null; + return {serializer, tessellator}; +} + +/** Tessellator tokens valid for a given serializer, from the backend's + * `enum_by` dependency map (falls back to the flat enum). */ +export function tessellatorsFor(schema: SerializerSchema, serializer: string): readonly string[] { + const bySer = schema.tessellator.enum_by?.[serializer]; + if (bySer && bySer.length) return bySer; + return schema.tessellator.enum ?? []; +} + +/** Resolve a (possibly partial) user selection into a fully-populated, + * schema-valid pick. Falls the serializer back to its default and the + * tessellator to the first token allowed for that serializer, so a stale or + * empty selection still sends coherent tokens. */ +export function normalizeSelection( + schema: SerializerSchema, + value: SerializerSelection, +): {serializer: string; tessellator: string; isClient: boolean} { + const serEnum = schema.serializer.enum ?? []; + let serializer = value.serializer && serEnum.includes(value.serializer) + ? value.serializer + : String(schema.serializer.default ?? serEnum[0] ?? ""); + const allowed = tessellatorsFor(schema, serializer); + const tessellator = value.tessellator && allowed.includes(value.tessellator) + ? value.tessellator + : allowed[0] ?? ""; + const isClient = schema.serializer.runtime?.[serializer] === "client"; + return {serializer, tessellator, isClient}; +} + +/** Convenience: does the current selection route to the in-browser pipeline? */ +export function selectionIsClient(ext: string, target: string, value: SerializerSelection): boolean { + const schema = serializerSchemaFor(ext, target); + if (!schema) return false; + return normalizeSelection(schema, value).isClient; +} diff --git a/src/frontend/src/services/conversion/serverPipeline.ts b/src/frontend/src/services/conversion/serverPipeline.ts index 372fb6bb6..02d5c5402 100644 --- a/src/frontend/src/services/conversion/serverPipeline.ts +++ b/src/frontend/src/services/conversion/serverPipeline.ts @@ -33,6 +33,9 @@ export async function convertViaServer( // (``runtime.conversionOptionsFor(ext, target)``); ConversionRow // collects them from the rendered option widgets. conversionOptions?: Record; + // Force a fresh re-conversion into the ``_reconvert/`` namespace (never touches the + // ``_derived/`` audit product). The returned derived_key points at ``_reconvert/…``. + reconvert?: boolean; }, ): Promise { // Track jobs per (source, format) so a parallel ifc + xml conversion @@ -43,7 +46,7 @@ export async function convertViaServer( opts?.step !== undefined && opts?.field !== undefined ? `::s${opts.step}.${opts.field}` : ""; - const storeKey = `${sourceKey}::${targetFormat}${pickSuffix}`; + const storeKey = `${sourceKey}::${targetFormat}${pickSuffix}${opts?.reconvert ? "::reconvert" : ""}`; const store = useConversionStore.getState(); const initial = await viewerApi.convert(scope, sourceKey, targetFormat, opts); @@ -84,6 +87,12 @@ export async function convertViaServer( if (payload.status === "error") { throw new Error(payload.error || "conversion failed"); } + // A user-killed job (or a watchdog-reaped one that surfaced as cancelled) is terminal — stop + // polling and reject, so callers awaiting the conversion (e.g. the gallery reconvert's + // "Re-converting…" state) reset instead of hanging until the ~30 min poll ceiling. + if (payload.status === "cancelled") { + throw new Error(payload.error || "conversion cancelled"); + } } throw new Error("conversion did not complete within the poll window"); } diff --git a/src/frontend/src/services/viewerApi.ts b/src/frontend/src/services/viewerApi.ts index c3b97aeef..aac07ccf8 100644 --- a/src/frontend/src/services/viewerApi.ts +++ b/src/frontend/src/services/viewerApi.ts @@ -66,7 +66,7 @@ export interface MeResponse { email: string; displayName: string; isAdmin: boolean; - scopes: Array<{kind: "shared" | "user" | "project"; id: string | null; name: string}>; + scopes: Array<{kind: "shared" | "user" | "project" | "corpus"; id: string | null; name: string}>; projects: Array<{id: string; slug: string; name: string; role: string}>; } @@ -595,6 +595,39 @@ export interface ConvertMeta { // as % utilization across all cores rather than a cumulative ramp. cpu_cores?: number | null; options?: Record; + // adacpp [STEPPROF-JSON] pipeline summaries, parsed from the captured child log when + // the profile_conversions toggle was on — one entry per instrumented C++ pipeline run. + cpp_profile?: CppProfile[]; +} + +export interface CppProfilePhase { + name: string; + ms: number; + rss_mb: number; +} + +export interface CppProfileThread { + tid: number; + solids: number; + busy_ms: number; +} + +export interface CppProfile { + label: string; + wall_ms: number; + peak_rss_mb: number; + cpu_s?: number; + parallelism?: number; + vctx?: number; + nvctx?: number; + disk_read_mb?: number; + majflt?: number; + solids?: number; + tris?: number; + max_tris_solid?: number; + phases: CppProfilePhase[]; + notes?: Record; + threads?: CppProfileThread[]; } export interface WorkerPackage { @@ -617,6 +650,9 @@ export interface AuditRun { // Idle time (ms) excluded from the active duration — the gap before a // later validation pass folded into the run. UI subtracts it. idle_ms?: number | null; + // Sum of every cell's own duration_ms — the run's active compute time, + // independent of wall clock. The UI shows this as the run's total runtime. + cells_duration_ms?: number | null; scope: string; worker_pool: string | null; trigger: string; @@ -625,6 +661,10 @@ export interface AuditRun { status: string; note: string | null; total: number; + // Auto-validation parity cells counted into `total` upfront but not yet + // enqueued (the poller dispatches them once the conversion cells land). + // 0 once the validation pass starts, and for non-auto-validate runs. + validate_total?: number; ok: number; failed: number; skipped: number; @@ -761,6 +801,16 @@ export interface AuditRunJob { // Empty for cells finished before migration 013 / cells that // hit the dispatcher's cached short-circuit. worker_image_tag: string | null; + // Per-conversion provenance + quality flags (JSONB). Includes + // ``occ_fallback`` ({count, reasons, geoms}) when the NGEOM/libtess2 path + // silently fell back to OCC, and ``mesh_flags`` for distorted triangles. + convert_meta?: { + occ_fallback?: {count: number; reasons?: Record; geoms?: Record}; + mesh_flags?: {distorted_tris?: number; distorted_frac?: number; n_tris?: number}; + // Faces with a trim boundary that tessellated to zero triangles (silently dropped geometry). + geom_health?: {dropped_faces?: number; total_faces?: number}; + [k: string]: unknown; + } | null; } export interface ProfileStatsRow { @@ -859,6 +909,8 @@ export interface AuditFilters { action?: string; /** Conversion target format (glb / ifc / step / …). */ target?: string; + /** Job state (queued / running / done / error). */ + status?: string; /** Case-insensitive substring filter on the source filepath/filename. */ key?: string; before_id?: number; @@ -1458,6 +1510,9 @@ export const viewerApi = { // ``null`` clears any global override; otherwise the // type matches the option's declared ``type``. conversionOptions?: Record; + // Re-convert: always re-run and write to the separate ``_reconvert/`` namespace so + // a corpus scope's ``_derived/`` audit product is never overwritten. + reconvert?: boolean; }, ): Promise { const body: Record = { @@ -1471,6 +1526,9 @@ export const viewerApi = { if (opts?.conversionOptions && Object.keys(opts.conversionOptions).length) { body.conversion_options = opts.conversionOptions; } + if (opts?.reconvert) { + body.reconvert = true; + } const r = await authedFetch( `${runtime.apiBase()}/scopes/${encodeURIComponent(scope)}/convert`, { @@ -1664,6 +1722,24 @@ export const viewerApi = { return jsonOrThrow(r, "adminCorpusCreate"); }, + /** Admin: update a corpus's display name / description. The slug + * is immutable (baked into the storage prefix + scope URLs). An + * empty description clears it. */ + async adminCorpusUpdate( + slug: string, + body: {name: string; description: string | null}, + ): Promise { + const r = await authedFetch( + `${runtime.apiBase()}/admin/corpora/${encodeURIComponent(slug)}`, + { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }, + ); + return jsonOrThrow(r, "adminCorpusUpdate"); + }, + /** Admin: soft-delete a corpus by slug. Storage bytes survive — * the operator clears those out-of-band. The slug becomes * immediately available for reuse. */ @@ -1714,6 +1790,22 @@ export const viewerApi = { return jsonOrThrow(r, `adminAuditRunReDispatch(${runId})`); }, + /** Admin: re-run a single cell of a run in place (right-click → Rerun). + * Enqueues one force-rebuild conversion for (key, target) against the run's + * own scope/pool and reopens the run; the other cells are untouched. + * Returns the (reopened) run. */ + async adminAuditRunRerunCell(runId: string, key: string, target: string): Promise { + const r = await authedFetch( + `${runtime.apiBase()}/admin/audit/runs/${encodeURIComponent(runId)}/rerun-cell`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({key, target}), + }, + ); + return jsonOrThrow(r, `adminAuditRunRerunCell(${runId})`); + }, + /** Admin: append a validation (cross-format parity) pass to a finished * run — folded into the same run, not a new one. 409 if the run isn't * finished or has already been validated. Returns the (reopened) run. */ diff --git a/src/frontend/src/state/auditToastStore.ts b/src/frontend/src/state/auditToastStore.ts new file mode 100644 index 000000000..58fd06162 --- /dev/null +++ b/src/frontend/src/state/auditToastStore.ts @@ -0,0 +1,21 @@ +import {create} from "zustand"; +import {persist} from "zustand/middleware"; + +// Whether the ambient "audit sweep in progress" toast is hidden. Toggled from the Audit Runs +// panel; the toast (ConversionProgress) reads it. Persisted so the operator's choice sticks. +interface AuditToastState { + hidden: boolean; + setHidden: (v: boolean) => void; + toggle: () => void; +} + +export const useAuditToastStore = create()( + persist( + (set) => ({ + hidden: false, + setHidden: (hidden) => set({hidden}), + toggle: () => set((s) => ({hidden: !s.hidden})), + }), + {name: "ada-audit-toast"}, + ), +); diff --git a/src/frontend/src/state/galleryStore.ts b/src/frontend/src/state/galleryStore.ts new file mode 100644 index 000000000..1042db681 --- /dev/null +++ b/src/frontend/src/state/galleryStore.ts @@ -0,0 +1,78 @@ +import {create} from "zustand"; +import {persist} from "zustand/middleware"; + +// Gallery mode: a small prev/next HUD. The "files" walk cycles the +// current scope's loadable FILES one at a time (clear → load). The +// "geoms" walk cycles the GEOMS already in the scene, selecting + +// framing each in turn (a slideshow of the model's parts), in one of +// three orders: +// - "scene": scene/batched-mesh traversal order (roughly load order). +// - "density": triangles per surface area — heaviest/most-detailed +// geometry first. +// - "tree": the model tree's hierarchy order (parent → children DFS). +// - "distorted": only geoms with a "crows-nest" spike (a thin triangle +// shooting out past the geometry), worst-first — the tessellation-bug +// inspector. Forces geometry edges on so the spikes are visible. +// "hideUnselected" optionally isolates the current geom during a geom +// walk (hides everything else) so it reads clearly. +// +// The enabled flag and the walk preferences persist (viewing +// preferences); the per-walk index is transient and lives in the +// GalleryControls component (it depends on the current scope / scene). +export type GalleryWalk = "files" | "geoms"; +export type GeomWalkOrder = "scene" | "density" | "tree" | "distorted"; + +interface GalleryState { + enabled: boolean; + setEnabled: (v: boolean) => void; + toggle: () => void; + + walk: GalleryWalk; + setWalk: (w: GalleryWalk) => void; + + geomOrder: GeomWalkOrder; + setGeomOrder: (o: GeomWalkOrder) => void; + + hideUnselected: boolean; + setHideUnselected: (v: boolean) => void; + toggleHideUnselected: () => void; + + // Measured height (px) of the mobile gallery bar so bottom-anchored overlays (the audit toast) + // can stack ABOVE it instead of overlapping. 0 when the bar isn't shown (desktop / gallery off). + // Transient — not persisted. + mobileBarHeight: number; + setMobileBarHeight: (h: number) => void; +} + +export const useGalleryStore = create()( + persist( + (set) => ({ + enabled: false, + setEnabled: (v) => set({enabled: v}), + toggle: () => set((s) => ({enabled: !s.enabled})), + + walk: "files", + setWalk: (walk) => set({walk}), + + geomOrder: "scene", + setGeomOrder: (geomOrder) => set({geomOrder}), + + hideUnselected: false, + setHideUnselected: (hideUnselected) => set({hideUnselected}), + toggleHideUnselected: () => set((s) => ({hideUnselected: !s.hideUnselected})), + + mobileBarHeight: 0, + setMobileBarHeight: (mobileBarHeight) => set({mobileBarHeight}), + }), + { + name: "ada-gallery", + // Persist only the viewing preferences; mobileBarHeight is transient runtime layout. + partialize: (s) => ({ + enabled: s.enabled, + walk: s.walk, + geomOrder: s.geomOrder, + hideUnselected: s.hideUnselected, + }), + }, + ), +); diff --git a/src/frontend/src/state/meshPanelStore.ts b/src/frontend/src/state/meshPanelStore.ts new file mode 100644 index 000000000..70cebbdb1 --- /dev/null +++ b/src/frontend/src/state/meshPanelStore.ts @@ -0,0 +1,39 @@ +import {create} from "zustand"; +import {persist} from "zustand/middleware"; +import {DEFAULT_SPIKE_THRESHOLDS} from "@/utils/mesh_select/meshStats"; + +// Editable spike thresholds for the Scene panel's "Mesh" mode (Scene dropdown → Mesh). The section +// scans every geom in the scene for "crows-nest" tessellation spikes (see meshStats.ts) and lists +// the offenders in a distortion-sorted table; these two thresholds let a scan be re-run tighter or +// looser than the gallery's fixed defaults, and persist as a viewing preference. Visibility is owned +// by sceneInfoStore (mode === "mesh"); scan RESULTS are transient in the section component. +interface MeshPanelState { + spikeAspectMin: number; + spikeOutlierK: number; + setSpikeAspectMin: (v: number) => void; + setSpikeOutlierK: (v: number) => void; + resetThresholds: () => void; +} + +export const useMeshPanelStore = create()( + persist( + (set) => ({ + spikeAspectMin: DEFAULT_SPIKE_THRESHOLDS.spikeAspectMin, + spikeOutlierK: DEFAULT_SPIKE_THRESHOLDS.spikeOutlierK, + setSpikeAspectMin: (spikeAspectMin) => set({spikeAspectMin}), + setSpikeOutlierK: (spikeOutlierK) => set({spikeOutlierK}), + resetThresholds: () => + set({ + spikeAspectMin: DEFAULT_SPIKE_THRESHOLDS.spikeAspectMin, + spikeOutlierK: DEFAULT_SPIKE_THRESHOLDS.spikeOutlierK, + }), + }), + { + name: "ada-mesh-panel", + partialize: (s) => ({ + spikeAspectMin: s.spikeAspectMin, + spikeOutlierK: s.spikeOutlierK, + }), + }, + ), +); diff --git a/src/frontend/src/state/model_worker/cacheModelUtils.ts b/src/frontend/src/state/model_worker/cacheModelUtils.ts index 10ef61a0e..50b13242d 100644 --- a/src/frontend/src/state/model_worker/cacheModelUtils.ts +++ b/src/frontend/src/state/model_worker/cacheModelUtils.ts @@ -1,6 +1,9 @@ // state/cacheModelUtils.ts import {modelStore} from "./modelStore"; import {useTreeViewStore} from "../treeViewStore"; +import {useOptionsStore} from "@/state/optionsStore"; +import {clearFaceHighlight} from "@/utils/mesh_select/faceHighlight"; +import {useObjectInfoStore} from "@/state/objectInfoStore"; import {TreeNodeData} from "@/components/tree_view/CustomNode"; /** @@ -51,9 +54,35 @@ export async function cacheAndBuildTree( >; } + // Opt-in per-face clickable regions: face_ranges_node = {rangeId: [[start,len,faceId,seq],...]} + // where start/len are relative to that solid's draw range. Present only for GLBs converted with + // face-region capture; absent otherwise (the faces toggle stays hidden). + const faceRanges: Record> = {}; + for (const k of Object.keys(rawUserData).filter((k) => + k.startsWith("face_ranges_node") + )) { + const idx = k.slice("face_ranges_node".length); + faceRanges[`node${idx}`] = rawUserData[k] as Record< + number, + [number, number, number, number][] + >; + } + + // Advertise face-region availability so the scene-info solid/faces toggle appears (or hides). + const hasFaceRegions = Object.keys(faceRanges).length > 0; + const opts = useOptionsStore.getState(); + opts.setFaceRegionsAvailable(hasFaceRegions); + // Loading a model that can't do face picking snaps the mode back to Solid, so the toggle (now + // hidden) can't leave clicks stuck on the raycast path with no face id ever resolving. + if (!hasFaceRegions && opts.faceLevelPicking) { + opts.setFaceLevelPicking(false); + clearFaceHighlight(); + useObjectInfoStore.getState().setClickedFace(null); + } + // 2) cache → IndexedDB try { - await modelStore.add(key, hierarchy, drawRanges); + await modelStore.add(key, hierarchy, drawRanges, faceRanges); } catch (err: unknown) { console.error("Failed to cache model metadata", err); // you could even early-return here if caching is critical diff --git a/src/frontend/src/state/model_worker/modelCache.worker.ts b/src/frontend/src/state/model_worker/modelCache.worker.ts index 32dff575f..50ff192bb 100644 --- a/src/frontend/src/state/model_worker/modelCache.worker.ts +++ b/src/frontend/src/state/model_worker/modelCache.worker.ts @@ -7,10 +7,13 @@ export interface ModelData { key: string; hierarchy: Record; drawRanges: Record>; + faceRanges?: FaceRangesMap; } // In-worker, we’ll keep an in-memory map from key → its drawRanges type DrawRangesMap = Record>; +// meshName → rangeId → list of [relStart, len, faceId, seq] (start/len relative to the draw range) +type FaceRangesMap = Record>; type HierarchyMap = Record; class ModelWorkerAPI { @@ -18,6 +21,7 @@ class ModelWorkerAPI { private models!: Table; private memoryCacheDrawRange = new Map(); private memoryCacheHierarchy = new Map(); + private memoryCacheFaceRange = new Map(); constructor() { this.db = new Dexie("ModelCacheDB"); @@ -25,11 +29,54 @@ class ModelWorkerAPI { this.models = this.db.table("models") as Table; } - async add(key: string, hierarchy: Record, drawRanges: Record>): Promise { - await this.models.put({key, hierarchy, drawRanges}); + async add(key: string, hierarchy: Record, drawRanges: Record>, faceRanges?: FaceRangesMap): Promise { + await this.models.put({key, hierarchy, drawRanges, faceRanges}); // keep in memory for super-fast lookups this.memoryCacheDrawRange.set(key, drawRanges); this.memoryCacheHierarchy.set(key, hierarchy); + if (faceRanges) this.memoryCacheFaceRange.set(key, faceRanges); + } + + /** True iff this model carries per-face clickable regions (opt-in face_ranges_node extras). */ + async hasFaceRanges(key: string): Promise { + const fr = this.memoryCacheFaceRange.get(key); + return !!fr && Object.keys(fr).length > 0; + } + + /** + * Resolve a clicked triangle to its source face region. Finds the object's draw range + * (faceStart ∈ [start,start+len)) then the face sub-range within it (offsets are relative to + * the draw-range start). Returns {rangeId, faceId, seq} or null. + */ + async getFaceInfo( + key: string, + meshName: string, + faceIndex: number + ): Promise<{rangeId: string; faceId: number; seq: number; start: number; length: number} | null> { + const faceRanges = this.memoryCacheFaceRange.get(key); + const drawRanges = this.memoryCacheDrawRange.get(key); + if (!faceRanges || !drawRanges) return null; + const facesForMesh = faceRanges[meshName]; + const rangesForMesh = drawRanges[meshName]; + if (!facesForMesh || !rangesForMesh) return null; + + const faceStart = faceIndex * 3; + for (const [rangeId, [start, length]] of Object.entries(rangesForMesh)) { + if (faceStart >= start && faceStart < start + length) { + const subs = facesForMesh[rangeId as unknown as number]; + if (!subs) return null; + const rel = faceStart - start; // face offsets are relative to the draw range + for (const [fStart, fLen, faceId, seq] of subs) { + if (rel >= fStart && rel < fStart + fLen) { + // start/length are ABSOLUTE index positions (draw-range start + face offset), + // ready for CustomBatchedMesh.highlightFaceRange. + return {rangeId, faceId, seq, start: start + fStart, length: fLen}; + } + } + return null; + } + } + return null; } async get(key: string): Promise { @@ -178,7 +225,11 @@ class ModelWorkerAPI { if (parent === "*" || parent === null) { root = nodes[string_id]; } else { - let parent_id = id_rangeIdMap.get(parent); + // id_hierarchy keys are JSON strings, but the parent field is emitted as a NUMBER by + // the native C++ writer (build_scene_extras: `hier << parent`), so a raw Map.get(0) + // misses the "0" key and the whole tree flattens ("No parent found for X (0)"). + // Coerce to string so numeric and string parents both resolve. + let parent_id = id_rangeIdMap.get(String(parent)); if (!parent_id) { console.warn( `ModelWorkerAPI: No parent found for ${string_id} (${parent})` diff --git a/src/frontend/src/state/model_worker/modelStore.ts b/src/frontend/src/state/model_worker/modelStore.ts index 02c373aad..25de738b6 100644 --- a/src/frontend/src/state/model_worker/modelStore.ts +++ b/src/frontend/src/state/model_worker/modelStore.ts @@ -15,7 +15,8 @@ export interface ModelStoreAPI { add( key: string, hierarchy: Record, - drawRanges: Record> + drawRanges: Record>, + faceRanges?: Record> ): Promise; get(key: string): Promise; @@ -60,6 +61,15 @@ export interface ModelStoreAPI { key: string, memberNames: string[] ): Promise>; // Returns [meshName, rangeId] pairs + + // Opt-in per-face clickable regions + hasFaceRanges(key: string): Promise; + + getFaceInfo( + key: string, + meshName: string, + faceIndex: number + ): Promise<{rangeId: string; faceId: number; seq: number; start: number; length: number} | null>; } const worker = new Worker(); diff --git a/src/frontend/src/state/objectInfoStore.ts b/src/frontend/src/state/objectInfoStore.ts index dbb5fd739..c477685ee 100644 --- a/src/frontend/src/state/objectInfoStore.ts +++ b/src/frontend/src/state/objectInfoStore.ts @@ -7,6 +7,10 @@ type ObjectInfoState = { setFaceIndex: (faceIndex: number | null) => void; clickCoordinate: { x: number; y: number, z: number } | null; setClickCoordinate: (clickCoordinate: { x: number; y: number, z: number } | null) => void; + // Source face region of the last click (opt-in face-level picking): STEP/IFC entity id + the + // 0-based face index within the solid. null when face picking is off or unavailable. + clickedFace: { faceId: number; seq: number } | null; + setClickedFace: (clickedFace: { faceId: number; seq: number } | null) => void; jsonData: any | null; setJsonData: (jsonData: any | null) => void; // Source file owning the currently-selected object. Passed back @@ -25,6 +29,8 @@ export const useObjectInfoStore = create((set) => ({ setFaceIndex: (faceIndex) => set(() => ({faceIndex})), clickCoordinate: null, setClickCoordinate: (clickCoordinate) => set(() => ({clickCoordinate})), + clickedFace: null, + setClickedFace: (clickedFace) => set(() => ({clickedFace})), jsonData: null, setJsonData: (jsonData) => set(() => ({jsonData})), fileName: null, diff --git a/src/frontend/src/state/optionsStore.ts b/src/frontend/src/state/optionsStore.ts index 2ac8916a9..f97715080 100644 --- a/src/frontend/src/state/optionsStore.ts +++ b/src/frontend/src/state/optionsStore.ts @@ -8,6 +8,8 @@ export type OptionsState = { isOptionsVisible: boolean; showPerf: boolean; showEdges: boolean; + // Show the "Mesh" tessellation-stats block inside the selection's Properties panel. + showMeshStats: boolean; hideTessellationEdges: boolean; lockTranslation: boolean; enableWebsocket: boolean; @@ -15,14 +17,30 @@ export type OptionsState = { pointSize: number; pointSizeAbsolute: boolean; useGpuPointPicking: boolean; + // Fit the camera to the whole model after each load (zoom-to-all). Default ON so a + // freshly loaded model — and each geom cycled through in gallery mode — is framed + // without a manual Shift+A. Off keeps the camera where it is across loads. + autoFit: boolean; // Auto-convert uploaded source files to GLB on upload. Default OFF — an // upload shouldn't silently spend worker time / spawn a conversion the user // didn't ask for; they trigger conversion explicitly from the file row. autoConvertOnUpload: boolean; + // Decimal places shown for the "Clicked at" coordinate row. Adjustable because a + // small model (a few mm across) needs more decimals before the displayed position + // changes at all between nearby clicks; a building-scale model wants fewer. + clickedCoordDecimals: number; + // Face-level picking (opt-in). When on, clicks resolve the exact source face region (STEP/IFC + // #id) via the raycast path instead of the fast GPU object-pick. Only meaningful for GLBs that + // carry face_ranges (the scene-info toggle is hidden otherwise). + faceLevelPicking: boolean; + // Runtime capability (not a user preference): the most recently loaded model carries per-face + // regions. Gates the solid/faces toggle so it only appears when face picking can actually work. + faceRegionsAvailable: boolean; setIsOptionsVisible: (value: boolean) => void; setShowPerf: (value: boolean) => void; setShowEdges: (value: boolean) => void; + setShowMeshStats: (value: boolean) => void; setHideTessellationEdges: (value: boolean) => void; setLockTranslation: (value: boolean) => void; setEnableWebsocket: (value: boolean) => void; @@ -30,13 +48,18 @@ export type OptionsState = { setPointSize: (value: number) => void; setPointSizeAbsolute: (value: boolean) => void; setUseGpuPointPicking: (value: boolean) => void; + setAutoFit: (value: boolean) => void; setAutoConvertOnUpload: (value: boolean) => void; + setClickedCoordDecimals: (value: number) => void; + setFaceLevelPicking: (value: boolean) => void; + setFaceRegionsAvailable: (value: boolean) => void; }; export const useOptionsStore = create((set) => ({ isOptionsVisible: false, showPerf: false, showEdges: true, + showMeshStats: true, hideTessellationEdges: true, lockTranslation: false, enableWebsocket: true, @@ -44,11 +67,16 @@ export const useOptionsStore = create((set) => ({ pointSize: 0.01, pointSizeAbsolute: true, useGpuPointPicking: true, + autoFit: true, autoConvertOnUpload: false, + clickedCoordDecimals: 3, + faceLevelPicking: false, + faceRegionsAvailable: false, setIsOptionsVisible: (v) => set({isOptionsVisible: v}), setShowPerf: (v) => set({showPerf: v}), setShowEdges: (v) => set({showEdges: v}), + setShowMeshStats: (v) => set({showMeshStats: v}), setHideTessellationEdges: (v) => set({hideTessellationEdges: v}), setLockTranslation: (v) => set({lockTranslation: v}), setEnableWebsocket: (v) => set({enableWebsocket: v}), @@ -56,5 +84,9 @@ export const useOptionsStore = create((set) => ({ setPointSize: (v) => set({pointSize: v}), setPointSizeAbsolute: (v) => set({pointSizeAbsolute: v}), setUseGpuPointPicking: (v) => set({useGpuPointPicking: v}), + setAutoFit: (v) => set({autoFit: v}), setAutoConvertOnUpload: (v) => set({autoConvertOnUpload: v}), + setClickedCoordDecimals: (v) => set({clickedCoordDecimals: v}), + setFaceLevelPicking: (v) => set({faceLevelPicking: v}), + setFaceRegionsAvailable: (v) => set({faceRegionsAvailable: v}), })); diff --git a/src/frontend/src/state/sceneInfoStore.ts b/src/frontend/src/state/sceneInfoStore.ts index 18533829d..c1ee360ce 100644 --- a/src/frontend/src/state/sceneInfoStore.ts +++ b/src/frontend/src/state/sceneInfoStore.ts @@ -45,7 +45,7 @@ export interface UtilityResult { summary?: Record; } -export type SceneInfoMode = 'info' | 'utilities' | 'section' | 'fem'; +export type SceneInfoMode = 'info' | 'utilities' | 'section' | 'fem' | 'mesh'; interface SceneInfoState { show_scene_info_box: boolean; diff --git a/src/frontend/src/state/scopeStore.ts b/src/frontend/src/state/scopeStore.ts index cd09e8bbf..95e3916b0 100644 --- a/src/frontend/src/state/scopeStore.ts +++ b/src/frontend/src/state/scopeStore.ts @@ -4,8 +4,9 @@ import {persist} from "zustand/middleware"; // One scope the user is browsing right now. Mirrors the backend's // /api/me response. export interface ScopeOption { - kind: "shared" | "user" | "project"; - id: string | null; // 'me' for user-scope here; null for shared + kind: "shared" | "user" | "project" | "corpus"; + // 'me' for user-scope; null for shared; the corpus slug for corpus. + id: string | null; name: string; } @@ -66,5 +67,6 @@ export function scopeUrlPart(s: ScopeOption | null): string { if (!s) return "shared"; if (s.kind === "shared") return "shared"; if (s.kind === "user") return "user:me"; // server resolves 'me' to the caller's sub + if (s.kind === "corpus") return `corpus:${s.id}`; // admin-only, gated server-side return `project:${s.id}`; } diff --git a/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts b/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts index e45b35c0c..5d207383a 100644 --- a/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts +++ b/src/frontend/src/utils/mesh_select/CustomBatchedMesh.ts @@ -39,6 +39,7 @@ export class CustomBatchedMesh extends THREE.Mesh { private edgeMesh?: THREE.LineSegments; private rangeIdToIndex?: Map; private edgeMaterial?: THREE.ShaderMaterial; + public edgesEligible = false; // true once a design edge overlay was built (persists across rebuilds) // Cached materials to avoid per-click allocations for non-vertex-colored meshes private _matSelected?: THREE.Material; @@ -48,6 +49,10 @@ export class CustomBatchedMesh extends THREE.Mesh { private _usesVertexColorsFlag: boolean = false; private _baseColors?: Float32Array; // snapshot of current animated/base colors private _selectionOverlay?: THREE.Mesh; + // Independent overlay for per-face highlighting (opt-in face picking) — a single index sub-range + // drawn in a distinct colour on top of object selection. Separate from _selectionOverlay so the + // two never fight over materials/groups. + private _faceOverlay?: THREE.Mesh; private _overlaySourceIndices?: Uint32Array; // mapping: overlay vertex i -> base vertex index // Class properties for raycasting @@ -236,6 +241,7 @@ export class CustomBatchedMesh extends THREE.Mesh { /** call this when you have a renderer and want the overlay in the scene */ public getEdgeOverlay(renderer: THREE.WebGLRenderer): THREE.LineSegments { + this.edgesEligible = true; // this mesh takes a design edge overlay (FEA meshes never call this) if (!this.edgeMesh) { // first‐time initialization const {geometry, rangeIdToIndex} = @@ -253,6 +259,19 @@ export class CustomBatchedMesh extends THREE.Mesh { return this.edgeMesh; } + /** Drop the cached edge overlay so the next getEdgeOverlay() rebuilds it from the CURRENT options + * (e.g. after hideTessellationEdges toggled feature-only vs full-triangulation edges). Returns the + * old LineSegments so the caller can remove it from the scene before re-adding the rebuilt one. */ + public invalidateEdgeOverlay(): THREE.LineSegments | undefined { + const old = this.edgeMesh; + (this.edgeMesh?.geometry as THREE.BufferGeometry | undefined)?.dispose(); + this.edgeMaterial?.dispose(); + this.edgeMesh = undefined; + this.edgeMaterial = undefined; + this.rangeIdToIndex = undefined; + return old; + } + public updateSelectionGroups(rangeIds: string[]) { this.selectedRanges = new Set(rangeIds); @@ -388,6 +407,7 @@ export class CustomBatchedMesh extends THREE.Mesh { * never falls. Idempotent; only disposes per-instance clones, never shared singletons. */ dispose(): void { this._disposeSelectionOverlay(); + this.clearFaceHighlight(); if (this.edgeMesh) { (this.edgeMesh.geometry as THREE.BufferGeometry | undefined)?.dispose(); this.edgeMesh = undefined; @@ -421,6 +441,72 @@ export class CustomBatchedMesh extends THREE.Mesh { } } + /** Highlight a single index sub-range (one source face) in a distinct colour, on top of object + * selection. start/length are absolute positions into this mesh's index buffer. Independent of + * the object-selection overlay. Pass length<=0 (or call clearFaceHighlight) to remove it. */ + public highlightFaceRange(start: number, length: number): void { + const srcGeom = this.geometry as THREE.BufferGeometry; + const idxAttr = srcGeom.getIndex(); + const idxCount = idxAttr ? idxAttr.count : (srcGeom.attributes.position?.count ?? 0); + if (!(length > 0) || idxCount === 0 || start < 0 || start >= idxCount) { + this.clearFaceHighlight(); + return; + } + const s = start; + const c = Math.min(length, idxCount - start); + + let overlayGeom: THREE.BufferGeometry; + if (this._faceOverlay) { + overlayGeom = this._faceOverlay.geometry as THREE.BufferGeometry; + } else { + // Reference (do NOT clone) the base attributes/index + morphs so the GPU morphs it for free + // and no extra vertex memory is used — same approach as the selection overlay. + overlayGeom = new THREE.BufferGeometry(); + if (srcGeom.index) overlayGeom.setIndex(srcGeom.index); + for (const name of Object.keys(srcGeom.attributes)) + overlayGeom.setAttribute(name, srcGeom.getAttribute(name)); + overlayGeom.morphAttributes = {} as any; + if (srcGeom.morphAttributes) + for (const mName of Object.keys(srcGeom.morphAttributes)) + (overlayGeom.morphAttributes as any)[mName] = (srcGeom.morphAttributes as any)[mName]; + overlayGeom.morphTargetsRelative = srcGeom.morphTargetsRelative === true; + + // Same blue as object selection — in Faces mode only the clicked face is painted (the rest + // of the solid keeps its normal colour), so it reads as a face-granular selection. + const visMat = selectedMaterial.clone(); + visMat.side = THREE.DoubleSide; + (visMat as any).morphTargets = true; + (visMat as any).polygonOffset = true; + (visMat as any).polygonOffsetFactor = -2; // sit in front of the base surface + (visMat as any).polygonOffsetUnits = -2; + const invMat = new THREE.MeshBasicMaterial({visible: false}); + (invMat as any).morphTargets = true; + + this._faceOverlay = new THREE.Mesh(overlayGeom, [visMat, invMat]); + this._faceOverlay.matrixAutoUpdate = true; + this._faceOverlay.layers.mask = this.layers.mask; + this.add(this._faceOverlay); + } + (this._faceOverlay as any).morphTargetInfluences = (this as any).morphTargetInfluences; + (this._faceOverlay as any).morphTargetDictionary = (this as any).morphTargetDictionary; + + // groups: [0,s) invisible, [s,s+c) the visible face, [s+c,end) invisible + overlayGeom.clearGroups(); + if (s > 0) overlayGeom.addGroup(0, s, 1); + overlayGeom.addGroup(s, c, 0); + if (s + c < idxCount) overlayGeom.addGroup(s + c, idxCount - (s + c), 1); + } + + /** Remove the per-face highlight overlay. Disposes only the cloned materials (the geometry shares + * base attributes — disposing it would free the base mesh's GPU buffers). */ + public clearFaceHighlight(): void { + if (!this._faceOverlay) return; + this.remove(this._faceOverlay); + const m = this._faceOverlay.material as THREE.Material | THREE.Material[]; + (Array.isArray(m) ? m : [m]).forEach((x) => x && x.dispose()); + this._faceOverlay = undefined; + } + private _rebuildSelectionOverlay(rangeIds: string[]): void { // Clear overlay when nothing selected if (!rangeIds || rangeIds.length === 0) { diff --git a/src/frontend/src/utils/mesh_select/faceHighlight.ts b/src/frontend/src/utils/mesh_select/faceHighlight.ts new file mode 100644 index 000000000..66dab0a25 --- /dev/null +++ b/src/frontend/src/utils/mesh_select/faceHighlight.ts @@ -0,0 +1,20 @@ +// Coordinates the single active per-face highlight across meshes: highlighting a face on one mesh +// clears any face highlight on the previously-highlighted mesh, so only the clicked face is ever lit. +import type {CustomBatchedMesh} from "./CustomBatchedMesh"; + +let current: CustomBatchedMesh | null = null; + +/** Highlight [start, length) (absolute index positions) on `mesh`, clearing the prior highlight. */ +export function setFaceHighlight(mesh: CustomBatchedMesh, start: number, length: number): void { + if (current && current !== mesh) current.clearFaceHighlight(); + current = mesh; + mesh.highlightFaceRange(start, length); +} + +/** Remove the active per-face highlight, if any. */ +export function clearFaceHighlight(): void { + if (current) { + current.clearFaceHighlight(); + current = null; + } +} diff --git a/src/frontend/src/utils/mesh_select/handleClickEmptySpace.ts b/src/frontend/src/utils/mesh_select/handleClickEmptySpace.ts index 315f266c1..de9b9224d 100644 --- a/src/frontend/src/utils/mesh_select/handleClickEmptySpace.ts +++ b/src/frontend/src/utils/mesh_select/handleClickEmptySpace.ts @@ -1,36 +1,26 @@ import {useSelectedObjectStore} from "@/state/useSelectedObjectStore"; -import {CustomBatchedMesh} from "./CustomBatchedMesh"; import * as THREE from "three"; import {Object3D} from "three"; import {clearPointSelectionMask} from "../scene/pointsImpostor"; import {sceneRef} from "@/state/refs"; +import {clearFaceHighlight} from "./faceHighlight"; +import {useObjectInfoStore} from "@/state/objectInfoStore"; -export function handleClickEmptySpace(event: MouseEvent) { - const selectedObjects = useSelectedObjectStore.getState().selectedObjects; - selectedObjects.forEach((drawRangeIds, mesh) => { - if (!(mesh instanceof CustomBatchedMesh)) { - // loop recursively through the children of the mesh - (mesh as Object3D).traverse((child: Object3D) => { - if (child instanceof CustomBatchedMesh) { - child.clearSelectionGroups(); - } - }); - } else if ((mesh as any).isPoints) { - clearPointSelectionMask(mesh as unknown as THREE.Points); - } else { - mesh.clearSelectionGroups(); - } - }); +export function handleClickEmptySpace(_event: MouseEvent) { + // Fully deselect: clearSelectedObjects clears each object's visual selection groups AND + // empties the selectedObjects map, so the info box / tree view also drop the selection. + // (Clearing only the visual groups, as this used to, left a stale selection in the store.) + useSelectedObjectStore.getState().clearSelectedObjects(); + // Drop any per-face highlight + its Properties row along with the object selection. + clearFaceHighlight(); + useObjectInfoStore.getState().setClickedFace(null); - // traverse over Three.POINTS in scene objects + // Also drop point-selection masks on any Points NOT tracked in the store. const scene = sceneRef.current; - if (!scene) { - return; - } - (scene.traverse((child: Object3D) => { - if (child instanceof THREE.Points) { - clearPointSelectionMask(child as THREE.Points); - } + if (!scene) return; + scene.traverse((child: Object3D) => { + if (child instanceof THREE.Points) { + clearPointSelectionMask(child as THREE.Points); } - )) + }); } \ No newline at end of file diff --git a/src/frontend/src/utils/mesh_select/handleClickMesh.ts b/src/frontend/src/utils/mesh_select/handleClickMesh.ts index 374df1b03..b10d2fe22 100644 --- a/src/frontend/src/utils/mesh_select/handleClickMesh.ts +++ b/src/frontend/src/utils/mesh_select/handleClickMesh.ts @@ -3,7 +3,9 @@ import * as THREE from "three"; import {CustomBatchedMesh} from "./CustomBatchedMesh"; import {useModelState} from "@/state/modelState"; import {useObjectInfoStore} from "@/state/objectInfoStore"; -import {queryMeshDrawRange, queryNameFromRangeId} from "./queryMeshDrawRange"; +import {queryMeshDrawRange, queryNameFromRangeId, queryFaceInfo} from "./queryMeshDrawRange"; +import {useOptionsStore} from "@/state/optionsStore"; +import {setFaceHighlight, clearFaceHighlight} from "./faceHighlight"; import {perform_selection} from "./perform_selection"; import {query_ws_server_mesh_info} from "./handlers/send_mesh_selected_info_callback"; import {useTreeViewStore} from "@/state/treeViewStore"; @@ -36,15 +38,19 @@ export async function handleClickMesh( // faceIndex → rangeId worker round-trip entirely; the raycast // fallback still uses it. let rangeId: string; + // The mesh-name key the metadata cache actually uses — mesh.name usually, but the native GLB path + // keys by node, so the draw-range lookup falls back to it. Track which one resolved so + // face-level picking below queries the SAME key (its earlier bug was using mesh.name unconditionally). + let resolvedMeshName = mesh.name; if (prefilledRangeId !== undefined) { rangeId = prefilledRangeId; } else { // ← await the worker lookup - const meshName = mesh.name; // e.g. "node0" - let drawRange = await queryMeshDrawRange(mesh.unique_key, meshName, faceIndex); + let drawRange = await queryMeshDrawRange(mesh.unique_key, resolvedMeshName, faceIndex); if (!drawRange) { - if (mesh.userData?.node_id) { - drawRange = await queryMeshDrawRange(mesh.unique_key, `node${mesh.userData?.node_id}`, faceIndex); + if (mesh.userData?.node_id != null) { + resolvedMeshName = `node${mesh.userData?.node_id}`; + drawRange = await queryMeshDrawRange(mesh.unique_key, resolvedMeshName, faceIndex); } if (!drawRange) { console.warn("selected mesh has no draw range"); @@ -54,7 +60,33 @@ export async function handleClickMesh( [rangeId] = drawRange; } - await perform_selection(mesh, shiftKey, rangeId); + const facePicking = useOptionsStore.getState().faceLevelPicking; + + // Face-level picking (opt-in): resolve the clicked triangle to its source face region (STEP/IFC + // #id) and highlight just that face in the selection colour. Try both mesh-name keys (mesh.name / + // node) since the GPU-pick path supplies a prefilled rangeId and skips the draw-range + // lookup that would otherwise resolve the key. faceIndex comes from the refined single-mesh raycast. + if (facePicking && typeof intersect.faceIndex === "number") { + let fi = await queryFaceInfo(mesh.unique_key, mesh.name, intersect.faceIndex); + if (!fi && mesh.userData?.node_id != null) { + fi = await queryFaceInfo(mesh.unique_key, `node${mesh.userData?.node_id}`, intersect.faceIndex); + } + useObjectInfoStore.getState().setClickedFace(fi ? {faceId: fi.faceId, seq: fi.seq} : null); + if (fi) setFaceHighlight(mesh, fi.start, fi.length); + else clearFaceHighlight(); + } else { + useObjectInfoStore.getState().setClickedFace(null); + clearFaceHighlight(); + } + + // Faces mode highlights ONLY the clicked face (the rest of the solid keeps its normal colour), so + // skip the whole-object selection overlay and drop any lingering one. The Properties panel still + // populates — it's gated on the object name set below, not on the selection store. + if (!facePicking) { + await perform_selection(mesh, shiftKey, rangeId); + } else { + useSelectedObjectStore.getState().clearSelectedObjects(); + } // update object info const last_selected_name = await queryNameFromRangeId(mesh.unique_key, rangeId); @@ -74,9 +106,9 @@ export async function handleClickMesh( useObjectInfoStore.getState().setJsonData(null); void query_ws_server_mesh_info(last_selected_name, faceIndex, activeFile); - // update tree selection + // update tree selection — only in Solid mode; Faces mode doesn't select the whole object const treeViewStore = useTreeViewStore.getState(); - if (treeViewStore.treeData && treeViewStore.tree && !treeViewStore.isTreeCollapsed) { + if (!facePicking && treeViewStore.treeData && treeViewStore.tree && !treeViewStore.isTreeCollapsed) { // flag programmatic change // @ts-ignore treeViewStore.tree.isProgrammaticChange = true; diff --git a/src/frontend/src/utils/mesh_select/meshStats.ts b/src/frontend/src/utils/mesh_select/meshStats.ts new file mode 100644 index 000000000..c2173cba1 --- /dev/null +++ b/src/frontend/src/utils/mesh_select/meshStats.ts @@ -0,0 +1,165 @@ +import * as THREE from "three"; +import {CustomBatchedMesh} from "./CustomBatchedMesh"; + +// Per-geom (draw-range) mesh statistics, computed client-side from the +// batched geometry buffer. Shared by the gallery "density" walk order +// (sort by triangles-per-area) and the Object Info "Mesh" section. +// +// A geom is one draw-range: [start, count] into the shared index +// buffer. Triangles = count / 3. "density" here is triangles per unit +// of surface AREA (m²) — the natural measure for the shell/plate +// meshes the viewer shows; volume is reported too for solids. Both are +// in local mesh space (metres in adapy's world). +export interface RangeStats { + triangles: number; + vertices: number; // distinct vertices referenced by this range + sizeX: number; + sizeY: number; + sizeZ: number; + volume: number; // bounding-box volume, m³ + area: number; // summed triangle area, m² + density: number; // triangles / area (0 when area is 0) + // "Crows-nest" spike score: how far the worst OUTLIER vertex sits from the mesh body, as a + // multiple of the median vertex→centroid distance. A vertex shooting out past the geometry (the + // crows-nest bug) scores high; benign geometry — even a deep thin extrusion whose side triangles + // reach right across the bbox, or a coarse curved surface — has all its vertices ON the body, so + // it scores ~1. Ranking by this walks the real spikes first. 0 for a tiny/degenerate mesh. + maxSpike: number; + spikeTris: number; // thin triangles that touch an outlier vertex (dist > SPIKE_OUTLIER_K · median) +} + +// A triangle is "thin" (needle-like) once longest-edge² / (2·area) exceeds this. +const SPIKE_ASPECT_MIN = 8; +// A vertex is an OUTLIER (crows-nest spike) once its distance from the robust (median) centroid +// exceeds this multiple of the median vertex distance. A compact body sits at ~1–3×; a genuine +// spike is far out. This is what a raw edge/bbox reach test can't do — a deep extrusion's long side +// edges look identical to a spike by length, but their vertices aren't outliers. +export const SPIKE_OUTLIER_K = 4; + +// The two spike thresholds, adjustable from the Mesh inspection panel so a scan can be re-run tighter +// or looser. The module constants above are the defaults (and what the gallery uses). +export interface SpikeThresholds { + spikeAspectMin: number; // longest-edge² / (2·area) over which a triangle is "thin" + spikeOutlierK: number; // vertex-distance / median over which a vertex is a crows-nest outlier +} +export const DEFAULT_SPIKE_THRESHOLDS: SpikeThresholds = { + spikeAspectMin: SPIKE_ASPECT_MIN, + spikeOutlierK: SPIKE_OUTLIER_K, +}; + +export function computeRangeStats( + mesh: CustomBatchedMesh, + rangeId: string, + thresholds: SpikeThresholds = DEFAULT_SPIKE_THRESHOLDS, +): RangeStats | null { + const geometry = mesh.geometry as THREE.BufferGeometry; + const pos = geometry.getAttribute("position") as THREE.BufferAttribute | undefined; + const indexAttr = geometry.getIndex(); + const range = mesh.drawRanges.get(rangeId); + if (!pos || !indexAttr || !range) return null; + + const [start, count] = range; + const idx = indexAttr.array as Uint16Array | Uint32Array; + const end = Math.min(start + count, idx.length); + + const bbox = new THREE.Box3(); + const seen = new Set(); + const a = new THREE.Vector3(); + const b = new THREE.Vector3(); + const c = new THREE.Vector3(); + const ab = new THREE.Vector3(); + const ac = new THREE.Vector3(); + const cross = new THREE.Vector3(); + const bc = new THREE.Vector3(); + let area = 0; + + for (let i = start; i + 2 < end; i += 3) { + const ia = idx[i]; + const ib = idx[i + 1]; + const ic = idx[i + 2]; + a.fromBufferAttribute(pos, ia); + b.fromBufferAttribute(pos, ib); + c.fromBufferAttribute(pos, ic); + bbox.expandByPoint(a); + bbox.expandByPoint(b); + bbox.expandByPoint(c); + seen.add(ia); + seen.add(ib); + seen.add(ic); + ab.subVectors(b, a); + ac.subVectors(c, a); + area += 0.5 * cross.crossVectors(ab, ac).length(); + } + + const size = new THREE.Vector3(); + if (!bbox.isEmpty()) bbox.getSize(size); + const triangles = Math.floor(count / 3); + const volume = size.x * size.y * size.z; + const density = area > 0 ? triangles / area : 0; + + // --- crows-nest spike detection (outlier vertices) --------------------------------------- + // Robust (median-per-axis) centroid, then median vertex→centroid distance as the body scale. A + // vertex whose distance exceeds SPIKE_OUTLIER_K × that median is a spike (shot out past the body). + const verts = [...seen]; + const median = (arr: number[]): number => { + if (arr.length === 0) return 0; + const s = arr.slice().sort((x, y) => x - y); + const m = s.length >> 1; + return s.length % 2 ? s[m] : 0.5 * (s[m - 1] + s[m]); + }; + const xs: number[] = []; + const ys: number[] = []; + const zs: number[] = []; + for (const vi of verts) { + xs.push(pos.getX(vi)); + ys.push(pos.getY(vi)); + zs.push(pos.getZ(vi)); + } + const cx = median(xs); + const cy = median(ys); + const cz = median(zs); + const dists = verts.map((_, k) => Math.hypot(xs[k] - cx, ys[k] - cy, zs[k] - cz)); + const medDist = median(dists); + let maxSpike = 0; + const outliers = new Set(); + if (medDist > 1e-9) { + for (let k = 0; k < verts.length; k++) { + const ratio = dists[k] / medDist; + if (ratio > maxSpike) maxSpike = ratio; + if (ratio > thresholds.spikeOutlierK) outliers.add(verts[k]); + } + } + + // Count thin triangles that touch an outlier vertex — only when outliers exist. + let spikeTris = 0; + if (outliers.size) { + for (let i = start; i + 2 < end; i += 3) { + const ia = idx[i]; + const ib = idx[i + 1]; + const ic = idx[i + 2]; + if (!outliers.has(ia) && !outliers.has(ib) && !outliers.has(ic)) continue; + a.fromBufferAttribute(pos, ia); + b.fromBufferAttribute(pos, ib); + c.fromBufferAttribute(pos, ic); + ab.subVectors(b, a); + ac.subVectors(c, a); + bc.subVectors(c, b); + const triArea = 0.5 * cross.crossVectors(ab, ac).length(); + const emax = Math.max(ab.length(), ac.length(), bc.length()); + if (triArea > 0 && emax * emax > thresholds.spikeAspectMin * 2 * triArea) spikeTris++; + } + } + + return { + triangles, + vertices: seen.size, + sizeX: size.x, + sizeY: size.y, + sizeZ: size.z, + volume, + area, + density, + maxSpike, + spikeTris, + }; +} diff --git a/src/frontend/src/utils/mesh_select/queryMeshDrawRange.ts b/src/frontend/src/utils/mesh_select/queryMeshDrawRange.ts index 03f3817d9..cacca4609 100644 --- a/src/frontend/src/utils/mesh_select/queryMeshDrawRange.ts +++ b/src/frontend/src/utils/mesh_select/queryMeshDrawRange.ts @@ -31,4 +31,19 @@ export async function queryPointRangeByRangeId( rangeId: string ): Promise<[number, number] | null> { return await modelStore.getPointRangeByRangeId(key, meshName, rangeId); +} + +// Per-face clickable regions (opt-in face_ranges_node): resolve a clicked triangle to its source +// face region (STEP/IFC entity id + sequential index within the solid), or null when the model +// carries no face regions / the triangle isn't in one. +export async function queryFaceInfo( + key: string, + meshName: string, + faceIndex: number +): Promise<{rangeId: string; faceId: number; seq: number; start: number; length: number} | null> { + return await modelStore.getFaceInfo(key, meshName, faceIndex); +} + +export async function queryHasFaceRanges(key: string): Promise { + return await modelStore.hasFaceRanges(key); } \ No newline at end of file diff --git a/src/frontend/src/utils/nativeConvert/brepWriterConverter.ts b/src/frontend/src/utils/nativeConvert/brepWriterConverter.ts new file mode 100644 index 000000000..b551bf89d --- /dev/null +++ b/src/frontend/src/utils/nativeConvert/brepWriterConverter.ts @@ -0,0 +1,48 @@ +// Main-thread wrapper for the in-browser native (no-pyodide) B-rep writer (STEP→IFC / IFC→STEP). +// Spins up the brepWriterConverter Web Worker (Comlink). Mirrors cadGlbConverter.ts. + +import * as Comlink from "comlink"; + +import BrepWriterConverterWorker from "./brepWriterConverter.worker.ts?worker&inline"; +import type {BrepWriterConverterAPI, NativeBrepWriteResult, BrepDir} from "./brepWriterConverter.worker"; + +export type {BrepDir, NativeBrepWriteResult} from "./brepWriterConverter.worker"; + +let worker: Worker | null = null; +let apiRemote: Comlink.Remote | null = null; + +function ensureApi(): Comlink.Remote { + if (!apiRemote) { + worker = new BrepWriterConverterWorker(); + apiRemote = Comlink.wrap(worker); + } + return apiRemote; +} + +/** Convert a STEP/IFC buffer to the other B-rep format in the worker. The buffer is transferred. */ +export async function nativeBrepWrite( + dir: BrepDir, + srcBytes: ArrayBuffer, + opts?: {schema?: string; maxSolids?: number}, +): Promise { + return ensureApi().convert(dir, Comlink.transfer(srcBytes, [srcBytes]), opts); +} + +/** Does this browser/worker support the OPFS-streaming tier for the B-rep writer? */ +export async function nativeBrepWriterOpfsAvailable(): Promise { + try { + return await ensureApi().opfsAvailable(); + } catch { + return false; + } +} + +/** Convert a STEP/IFC at a (presigned, streamable) URL, streaming the source through OPFS so a large + * B-rep file never has to fit the wasm heap. Requires nativeBrepWriterOpfsAvailable(). */ +export async function nativeBrepWriteStreaming( + dir: BrepDir, + sourceUrl: string, + opts?: {schema?: string; maxSolids?: number}, +): Promise { + return ensureApi().convertStreaming(dir, sourceUrl, opts); +} diff --git a/src/frontend/src/utils/nativeConvert/brepWriterConverter.worker.ts b/src/frontend/src/utils/nativeConvert/brepWriterConverter.worker.ts new file mode 100644 index 000000000..cda09f8f9 --- /dev/null +++ b/src/frontend/src/utils/nativeConvert/brepWriterConverter.worker.ts @@ -0,0 +1,130 @@ +// Web Worker: native (no-pyodide) B-rep writer — STEP→IFC and IFC→STEP entirely in the browser via +// the OCC-free adacpp_brep_writer embind module (StreamIndex/IfcResolver readers → ifc_emit/step_emit +// emitters; no tessellation, no OCC, no ifcopenshell, no Python). ~1.3 MB. Buffered MEMFS path + +// OPFS-streaming tier (mountOpfs) for very large B-rep files, sharing opfsWasmfs with cadGlbConverter. +// +// embind surface (adacpp/src/cad/brep_writer_wasm.cpp): +// stepToIfc(inPath, outPath, schema, deflection, angularDeg, maxSolids) -> products (0 = none/error) +// ifcToStep(inPath, outPath, deflection, angularDeg, maxSolids) -> products +// mountOpfs(mountPoint) -> 0 ok + +import * as Comlink from "comlink"; + +import {loadEmscriptenModule} from "@/utils/wasm/emscriptenLoader"; +import {WasmfsModule, OPFS_MOUNT, ensureOpfsMounted, streamUrlToOpfs, unlinkAll} from "./opfsWasmfs"; + +const WASM_URL = "/wasm/adacpp_brep_writer.js"; + +// Conversion direction. The wire target (ifc/step) determines which verb runs. +export type BrepDir = "step2ifc" | "ifc2step"; + +interface EmscriptenFS { + writeFile(path: string, data: Uint8Array): void; + readFile(path: string): Uint8Array; + unlink(path: string): void; + mkdir(path: string): void; + open(path: string, flags: string): unknown; + write(stream: unknown, buffer: Uint8Array, offset: number, length: number, position: number): number; + close(stream: unknown): void; +} +interface EmModule extends WasmfsModule { + FS: EmscriptenFS; + stepToIfc( + inPath: string, + outPath: string, + schema: string, + deflection: number, + angularDeg: number, + maxSolids: number, + ): number; + ifcToStep(inPath: string, outPath: string, deflection: number, angularDeg: number, maxSolids: number): number; + mountOpfs(mountPoint: string): number; +} + +let modulePromise: Promise | null = null; +function getModule(): Promise { + if (!modulePromise) modulePromise = loadEmscriptenModule(WASM_URL); + return modulePromise; +} + +export interface NativeBrepWriteResult { + output: ArrayBuffer; + products: number; + ms: number; +} + +// AP242 face-set fallback tolerances (only used for geometry the analytic emitter can't represent); +// match the adapy production defaults. IFC schema for the STEP→IFC direction. +const DEFAULT_DEFLECTION = 2.0; +const DEFAULT_ANGULAR_DEG = 20.0; +const DEFAULT_IFC_SCHEMA = "IFC4X3_ADD2"; + +const srcExt = (dir: BrepDir) => (dir === "step2ifc" ? "step" : "ifc"); +const outExt = (dir: BrepDir) => (dir === "step2ifc" ? "ifc" : "step"); + +// Run the writer verb for `dir` on inPath → outPath. Returns products (>0 success). +function runWriter(Module: EmModule, dir: BrepDir, inPath: string, outPath: string, schema: string, maxSolids: number) { + return dir === "step2ifc" + ? Module.stepToIfc(inPath, outPath, schema, DEFAULT_DEFLECTION, DEFAULT_ANGULAR_DEG, maxSolids) + : Module.ifcToStep(inPath, outPath, DEFAULT_DEFLECTION, DEFAULT_ANGULAR_DEG, maxSolids); +} + +const api = { + // Can this worker OPFS-stream? (mount is the capability gate; worker-only.) + async opfsAvailable(): Promise { + return ensureOpfsMounted(await getModule()); + }, + + // Buffered path: source bytes → MEMFS → output. Simplest; fine below the OPFS threshold. + async convert( + dir: BrepDir, + srcBytes: ArrayBuffer, + opts?: {schema?: string; maxSolids?: number}, + ): Promise { + const Module = await getModule(); + const t0 = performance.now(); + const inPath = `/in.${srcExt(dir)}`; + const outPath = `/out.${outExt(dir)}`; + Module.FS.writeFile(inPath, new Uint8Array(srcBytes)); + const products = runWriter(Module, dir, inPath, outPath, opts?.schema ?? DEFAULT_IFC_SCHEMA, opts?.maxSolids ?? 0); + if (products <= 0) { + throw new Error(`native ${dir === "step2ifc" ? "STEP→IFC" : "IFC→STEP"} wrote no products`); + } + const out = Module.FS.readFile(outPath); + const output = out.slice().buffer; + unlinkAll(Module, [inPath, outPath]); + const result: NativeBrepWriteResult = {output, products, ms: performance.now() - t0}; + return Comlink.transfer(result, [output]); + }, + + // OPFS-streaming path: stream a (presigned) URL into OPFS through WASMFS, write off-disk, read the + // output back through WASMFS. For B-rep files too large to buffer in the wasm heap. + async convertStreaming( + dir: BrepDir, + sourceUrl: string, + opts?: {schema?: string; maxSolids?: number}, + ): Promise { + const Module = await getModule(); + if (!ensureOpfsMounted(Module)) { + throw new Error("OPFS streaming unavailable in this worker (OPFS backend not mountable)"); + } + const t0 = performance.now(); + const inPath = `${OPFS_MOUNT}/adacpp_brep_in.${srcExt(dir)}`; + const outPath = `${OPFS_MOUNT}/adacpp_brep_out.${outExt(dir)}`; + await streamUrlToOpfs(Module, inPath, sourceUrl); + const cleanup = () => unlinkAll(Module, [inPath, outPath]); + const products = runWriter(Module, dir, inPath, outPath, opts?.schema ?? DEFAULT_IFC_SCHEMA, opts?.maxSolids ?? 0); + if (products <= 0) { + cleanup(); + throw new Error(`native streaming ${dir === "step2ifc" ? "STEP→IFC" : "IFC→STEP"} wrote no products`); + } + const out = Module.FS.readFile(outPath); + const output = out.slice().buffer; + cleanup(); + const result: NativeBrepWriteResult = {output, products, ms: performance.now() - t0}; + return Comlink.transfer(result, [output]); + }, +}; + +export type BrepWriterConverterAPI = typeof api; +Comlink.expose(api); diff --git a/src/frontend/src/utils/nativeConvert/cadGlbConverter.ts b/src/frontend/src/utils/nativeConvert/cadGlbConverter.ts new file mode 100644 index 000000000..3aadec383 --- /dev/null +++ b/src/frontend/src/utils/nativeConvert/cadGlbConverter.ts @@ -0,0 +1,62 @@ +// Main-thread wrapper for the in-browser native (no-pyodide) CAD -> GLB converter (STEP + IFC). Spins +// up the cadGlbConverter Web Worker (Comlink) and exposes kind-generic entry points. Mirrors +// diffConverter.ts. + +import * as Comlink from "comlink"; + +import CadGlbConverterWorker from "./cadGlbConverter.worker.ts?worker&inline"; +import type {CadGlbConverterAPI, NativeCadGlbResult, CadKind} from "./cadGlbConverter.worker"; + +export type {CadKind, NativeCadGlbResult} from "./cadGlbConverter.worker"; + +let worker: Worker | null = null; +let apiRemote: Comlink.Remote | null = null; + +function ensureApi(): Comlink.Remote { + if (!apiRemote) { + worker = new CadGlbConverterWorker(); + apiRemote = Comlink.wrap(worker); + } + return apiRemote; +} + +// adapy production tessellation defaults (see adacpp step_to_glb_single / stream_ifc_to_glb). +const DEFAULT_DEFLECTION = 2.0; +const DEFAULT_ANGULAR_DEG = 20.0; + +function tessOpts(opts?: {deflection?: number; angularDeg?: number; meshopt?: boolean}) { + return { + deflection: opts?.deflection ?? DEFAULT_DEFLECTION, + angularDeg: opts?.angularDeg ?? DEFAULT_ANGULAR_DEG, + meshopt: opts?.meshopt ?? true, + }; +} + +/** Convert a STEP/IFC buffer to GLB in the worker. The buffer is transferred (consumed). */ +export async function nativeCadToGlb( + kind: CadKind, + srcBytes: ArrayBuffer, + opts?: {deflection?: number; angularDeg?: number; meshopt?: boolean}, +): Promise { + return ensureApi().toGlb(kind, Comlink.transfer(srcBytes, [srcBytes]), tessOpts(opts)); +} + +/** Does this browser/worker support the OPFS-streaming tier (worker-only sync access handles + a + * mountable OPFS backend) for the given module? */ +export async function nativeCadGlbOpfsAvailable(kind: CadKind): Promise { + try { + return await ensureApi().opfsAvailable(kind); + } catch { + return false; + } +} + +/** Convert a STEP/IFC at a (presigned, streamable) URL to GLB, streaming the source through OPFS so a + * multi-GB source never has to fit the wasm heap. Requires nativeCadGlbOpfsAvailable(kind). */ +export async function nativeCadToGlbStreaming( + kind: CadKind, + sourceUrl: string, + opts?: {deflection?: number; angularDeg?: number; meshopt?: boolean}, +): Promise { + return ensureApi().toGlbStreaming(kind, sourceUrl, tessOpts(opts)); +} diff --git a/src/frontend/src/utils/nativeConvert/cadGlbConverter.worker.ts b/src/frontend/src/utils/nativeConvert/cadGlbConverter.worker.ts new file mode 100644 index 000000000..c3a340665 --- /dev/null +++ b/src/frontend/src/utils/nativeConvert/cadGlbConverter.worker.ts @@ -0,0 +1,167 @@ +// Web Worker: CAD (STEP / IFC) -> GLB entirely in the browser via the OCC-free, NO-pyodide adacpp +// embind wasm modules (native Part-21 / IfcResolver reader + libtess2 + meshoptimizer + GLB writer). +// The lightweight counterpart to the pyodide pipeline — ~2.3 MB per module, no Python runtime. +// +// embind surface (see adacpp/src/cad/{cad_wasm,ifc_glb_wasm}.cpp): +// stepToGlb / ifcToGlb(inPath, outPath, spillDir, deflection, angularDeg, meshopt) -> product count +// (<0 on error) +// mountOpfs(mountPoint) -> 0 ok +// IO goes through the emscripten FS. Buffered path uses in-heap MEMFS; the OPFS-streaming path mounts +// OPFS via WASMFS so a multi-GB source reads off-disk via `pread` (bounded RSS), never the wasm heap. + +import * as Comlink from "comlink"; + +import {loadEmscriptenModule} from "@/utils/wasm/emscriptenLoader"; +import {WasmfsModule, OPFS_MOUNT, ensureOpfsMounted, streamUrlToOpfs, unlinkAll} from "./opfsWasmfs"; + +// Which native module + verb + source extension a `kind` maps to. Each module ships its own single +// verb; both share the FS + mountOpfs surface. +export type CadKind = "step" | "ifc"; +const MODULES: Record = { + step: {url: "/wasm/adacpp_step_glb.js", verb: "stepToGlb", ext: "step"}, + ifc: {url: "/wasm/adacpp_ifc_glb.js", verb: "ifcToGlb", ext: "ifc"}, +}; + +interface EmscriptenFS { + writeFile(path: string, data: Uint8Array): void; + readFile(path: string): Uint8Array; + mkdir(path: string): void; + unlink(path: string): void; + open(path: string, flags: string): unknown; + write(stream: unknown, buffer: Uint8Array, offset: number, length: number, position: number): number; + close(stream: unknown): void; +} +interface EmModule extends WasmfsModule { + FS: EmscriptenFS; + mountOpfs(mountPoint: string): number; + // The per-module conversion verb (stepToGlb / ifcToGlb) — same signature, looked up by name. + [verb: string]: unknown; +} +type ConvertVerb = ( + inPath: string, + outPath: string, + spillDir: string, + deflection: number, + angularDeg: number, + meshopt: boolean, +) => number; + +const modulePromises: Partial>> = {}; +function getModule(kind: CadKind): Promise { + let p = modulePromises[kind]; + if (!p) { + p = loadEmscriptenModule(MODULES[kind].url); + modulePromises[kind] = p; + } + return p; +} +function convertVerb(Module: EmModule, kind: CadKind): ConvertVerb { + return Module[MODULES[kind].verb] as ConvertVerb; +} + +export interface NativeCadGlbResult { + glb: ArrayBuffer; + products: number; // products (STEP solids / IFC products) written, not triangles + ms: number; +} + +// OPFS-mounted streaming file paths (all under OPFS_MOUNT so every byte lives on disk, not the wasm +// heap). Streaming write + mount are the shared opfsWasmfs helpers. Names are namespaced per module. +const opfsInPath = (kind: CadKind) => `${OPFS_MOUNT}/adacpp_${kind}glb_in.${MODULES[kind].ext}`; +const opfsOutPath = (kind: CadKind) => `${OPFS_MOUNT}/adacpp_${kind}glb_out.glb`; +const opfsSpillDir = (kind: CadKind) => `${OPFS_MOUNT}/adacpp_${kind}glb_spill`; + +const api = { + // Can this worker run the OPFS-streaming tier for `kind`? Mounting OPFS (worker-only) is the real + // capability gate; the pipeline decides WHEN to use it (large sources with a presigned URL). + async opfsAvailable(kind: CadKind): Promise { + return ensureOpfsMounted(await getModule(kind)); + }, + + // OPFS-streaming path: stream a (presigned) URL into OPFS, tessellate off-disk via pread, write + // the GLB to OPFS, read it back through WASMFS. For multi-GB sources that can't fit the wasm heap. + async toGlbStreaming( + kind: CadKind, + sourceUrl: string, + opts: {deflection: number; angularDeg: number; meshopt: boolean}, + ): Promise { + const Module = await getModule(kind); + if (!ensureOpfsMounted(Module)) { + throw new Error("OPFS streaming unavailable in this worker (OPFS backend not mountable)"); + } + const t0 = performance.now(); + const inPath = opfsInPath(kind); + await streamUrlToOpfs(Module, inPath, sourceUrl); + const outPath = opfsOutPath(kind); + try { + Module.FS.mkdir(opfsSpillDir(kind)); + } catch { + /* already exists on a reused module */ + } + const cleanup = () => unlinkAll(Module, [inPath, outPath]); + const products = convertVerb(Module, kind)( + inPath, + outPath, + opfsSpillDir(kind), + opts.deflection, + opts.angularDeg, + opts.meshopt, + ); + if (products < 0) { + cleanup(); + throw new Error(`native streaming ${kind.toUpperCase()}→GLB failed (I/O error in the wasm module)`); + } + // Read the OPFS-written GLB back through WASMFS (all IO goes through WASMFS — consistent, no + // cross-boundary flush race). Materialises the output once — the GLB is far smaller than source. + const out = Module.FS.readFile(outPath); + const glb = out.slice().buffer; + cleanup(); + const result: NativeCadGlbResult = {glb, products, ms: performance.now() - t0}; + return Comlink.transfer(result, [glb]); + }, + + // Buffered path: source bytes -> MEMFS (in-heap) -> GLB. Simplest; fine below the OPFS threshold. + async toGlb( + kind: CadKind, + srcBytes: ArrayBuffer, + opts: {deflection: number; angularDeg: number; meshopt: boolean}, + ): Promise { + const Module = await getModule(kind); + const inPath = `/in.${MODULES[kind].ext}`; + const outPath = "/out.glb"; + const spillDir = "/spill"; + const t0 = performance.now(); + Module.FS.writeFile(inPath, new Uint8Array(srcBytes)); + try { + Module.FS.mkdir(spillDir); + } catch { + /* already exists on a reused module — fine */ + } + const products = convertVerb(Module, kind)( + inPath, + outPath, + spillDir, + opts.deflection, + opts.angularDeg, + opts.meshopt, + ); + if (products < 0) { + throw new Error(`native ${kind.toUpperCase()}→GLB failed (I/O error in the wasm module)`); + } + const out = Module.FS.readFile(outPath); + // Own a transferable copy off the wasm heap, then release the FS entries so a reused module + // doesn't accumulate files across conversions. + const glb = out.slice().buffer; + try { + Module.FS.unlink(inPath); + Module.FS.unlink(outPath); + } catch { + /* best-effort cleanup */ + } + const result: NativeCadGlbResult = {glb, products, ms: performance.now() - t0}; + return Comlink.transfer(result, [glb]); + }, +}; + +export type CadGlbConverterAPI = typeof api; +Comlink.expose(api); diff --git a/src/frontend/src/utils/nativeConvert/opfsWasmfs.ts b/src/frontend/src/utils/nativeConvert/opfsWasmfs.ts new file mode 100644 index 000000000..f25551456 --- /dev/null +++ b/src/frontend/src/utils/nativeConvert/opfsWasmfs.ts @@ -0,0 +1,90 @@ +// Shared OPFS-via-WASMFS streaming helpers for the native (no-pyodide) adacpp embind modules +// (cadGlbConverter, brepWriterConverter). All IO goes THROUGH WASMFS — never the browser OPFS API — +// so a file streamed in is immediately visible to the C++ `pread` (WASMFS caches its dir listing at +// mount time, so a browser-OPFS-created file can be invisible to the module). Writing through the +// OPFS-mounted path still lands each chunk on disk (bounded RSS), so multi-GB sources never have to +// fit the wasm heap. + +// The WASMFS mount point (maps to the OPFS root). Each module has its OWN FS, so mounting the same +// point in different modules is independent. +export const OPFS_MOUNT = "/opfs"; + +// Minimal surface the helpers need — every adacpp embind module built with -sWASMFS + EXPORTED FS +// satisfies this structurally. +export interface WasmfsModule { + FS: { + readFile(path: string): Uint8Array; + unlink(path: string): void; + mkdir(path: string): void; + open(path: string, flags: string): unknown; + write(stream: unknown, buffer: Uint8Array, offset: number, length: number, position: number): number; + close(stream: unknown): void; + }; + mountOpfs(mountPoint: string): number; +} + +// wasmfs_create_opfs_backend() ABORTS the whole module (fatal) if called on the main browser thread +// without JSPI — which would also kill the buffered fallback that shares the module. It is only safe +// inside a dedicated Worker (emscripten_is_main_browser_thread() == false there). Callers always run +// in a Worker, but guard anyway so a mount can never abort the shared module. +function inDedicatedWorker(): boolean { + return ( + typeof (globalThis as {WorkerGlobalScope?: unknown}).WorkerGlobalScope !== "undefined" && + typeof (globalThis as {DedicatedWorkerGlobalScope?: unknown}).DedicatedWorkerGlobalScope !== "undefined" && + (globalThis as unknown as {self?: unknown}).self instanceof + (globalThis as unknown as {DedicatedWorkerGlobalScope: new () => unknown}).DedicatedWorkerGlobalScope + ); +} + +// Per-module "mounted" latch (modules are distinct objects; WeakSet keeps this GC-friendly). +const mounted = new WeakSet(); + +/** Mount OPFS at OPFS_MOUNT for this module (once). Returns false when not in a worker or OPFS isn't + * available — the caller then falls back to the buffered MEMFS path. */ +export function ensureOpfsMounted(Module: WasmfsModule): boolean { + if (mounted.has(Module)) return true; + if (!inDedicatedWorker()) return false; + try { + if (Module.mountOpfs(OPFS_MOUNT) === 0) { + mounted.add(Module); + return true; + } + } catch { + /* OPFS unavailable — caller falls back to the buffered MEMFS path */ + } + return false; +} + +/** Stream `sourceUrl` into `inPath` (under OPFS_MOUNT) THROUGH WASMFS, chunk by chunk. Each chunk + * writes straight through to OPFS at its offset and is freed; nothing accumulates in the heap, and the + * file is immediately visible to the module's `pread`. */ +export async function streamUrlToOpfs(Module: WasmfsModule, inPath: string, sourceUrl: string): Promise { + const resp = await fetch(sourceUrl); + if (!resp.ok || !resp.body) { + throw new Error(`fetch source failed: ${resp.status} ${resp.statusText}`); + } + const stream = Module.FS.open(inPath, "w"); + try { + const reader = resp.body.getReader(); + let pos = 0; + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + Module.FS.write(stream, value, 0, value.byteLength, pos); + pos += value.byteLength; + } + } finally { + Module.FS.close(stream); + } +} + +/** Best-effort FS.unlink of every path (ignore missing). */ +export function unlinkAll(Module: WasmfsModule, paths: string[]): void { + for (const p of paths) { + try { + Module.FS.unlink(p); + } catch { + /* best-effort cleanup */ + } + } +} diff --git a/src/frontend/src/utils/scene/centerViewOnSelection.ts b/src/frontend/src/utils/scene/centerViewOnSelection.ts index bf6d64945..abc3ae130 100644 --- a/src/frontend/src/utils/scene/centerViewOnSelection.ts +++ b/src/frontend/src/utils/scene/centerViewOnSelection.ts @@ -5,6 +5,7 @@ import {useModelState} from '@/state/modelState'; import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls'; import CameraControls from 'camera-controls'; import {selectedPointRef} from "@/state/refs"; +import {applyAdaptiveClipping} from "@/components/viewer/sceneHelpers/adaptiveClipping"; export const centerViewOnSelection = ( controls: OrbitControls | CameraControls, @@ -139,6 +140,11 @@ function center_on_bounding_box(boundingBox: THREE.Box3, camera: Camera, fillFac return; } + // Adapt near/far to the framed selection size so zooming into a small selection doesn't clip. + if (camera instanceof THREE.PerspectiveCamera) { + applyAdaptiveClipping(camera, controls, radius); + } + let distance = 0; if (camera instanceof THREE.PerspectiveCamera) { const fov = (camera.fov * Math.PI) / 180; // Radians diff --git a/src/frontend/src/utils/scene/galleryWalk.ts b/src/frontend/src/utils/scene/galleryWalk.ts new file mode 100644 index 000000000..ad841bbe3 --- /dev/null +++ b/src/frontend/src/utils/scene/galleryWalk.ts @@ -0,0 +1,204 @@ +import {sceneRef, cameraRef, controlsRef} from "@/state/refs"; +import {CustomBatchedMesh} from "@/utils/mesh_select/CustomBatchedMesh"; +import {useSelectedObjectStore} from "@/state/useSelectedObjectStore"; +import {useObjectInfoStore} from "@/state/objectInfoStore"; +import {useModelState} from "@/state/modelState"; +import {useTreeViewStore} from "@/state/treeViewStore"; +import {requestRender} from "@/state/perfStore"; +import {centerViewOnSelection} from "@/utils/scene/centerViewOnSelection"; +import {unhideAllRanges} from "@/utils/scene/visibility"; +import {queryNameFromRangeId} from "@/utils/mesh_select/queryMeshDrawRange"; +import {query_ws_server_mesh_info} from "@/utils/mesh_select/handlers/send_mesh_selected_info_callback"; +import {computeRangeStats, DEFAULT_SPIKE_THRESHOLDS} from "@/utils/mesh_select/meshStats"; +import type {SpikeThresholds} from "@/utils/mesh_select/meshStats"; +import {useOptionsStore} from "@/state/optionsStore"; +import type {GeomWalkOrder} from "@/state/galleryStore"; + +// Geom-level gallery walk: enumerate every draw-range (geom) currently +// in the scene, then select + frame them one at a time. The walk order +// is "scene" (traversal order), "density" (triangles per surface area, +// heaviest first), "tree" (the model tree's hierarchy order), or +// "distorted" (only geoms with a crows-nest spike, worst-first). + +export interface GeomEntry { + mesh: CustomBatchedMesh; + rangeId: string; + triangles: number; + density: number; + spike: number; // worst thin-triangle reach (fraction of bbox diagonal); see meshStats maxSpike + spikeTris: number; // how many spike triangles this geom has +} + +export type WalkOrder = GeomWalkOrder; + +function allBatchedMeshes(): CustomBatchedMesh[] { + const out: CustomBatchedMesh[] = []; + sceneRef.current?.traverse((o) => { + if (o instanceof CustomBatchedMesh) out.push(o); + }); + return out; +} + +function sceneOrderEntries( + meshes: CustomBatchedMesh[], + withStats: boolean, + thresholds: SpikeThresholds = DEFAULT_SPIKE_THRESHOLDS, +): GeomEntry[] { + const entries: GeomEntry[] = []; + for (const mesh of meshes) { + for (const rangeId of mesh.drawRanges.keys()) { + if (withStats) { + const s = computeRangeStats(mesh, rangeId, thresholds); + entries.push({ + mesh, + rangeId, + triangles: s?.triangles ?? 0, + density: s?.density ?? 0, + spike: s?.maxSpike ?? 0, + spikeTris: s?.spikeTris ?? 0, + }); + } else { + const range = mesh.drawRanges.get(rangeId)!; + entries.push({mesh, rangeId, triangles: Math.floor(range[1] / 3), density: 0, spike: 0, spikeTris: 0}); + } + } + } + return entries; +} + +function treeOrderEntries(meshes: CustomBatchedMesh[]): GeomEntry[] { + const byKey = new Map(); + for (const m of meshes) byKey.set(m.unique_key, m); + + const treeData = useTreeViewStore.getState().treeData; + if (!treeData) return sceneOrderEntries(meshes, false); + + const entries: GeomEntry[] = []; + const seen = new Set(); + const visit = (node: any) => { + const rid = node?.rangeId; + const mk = node?.model_key; + if (rid != null && mk != null) { + const mesh = byKey.get(String(mk)); + const rangeId = String(rid); + const dedupKey = `${mk}|${rangeId}`; + if (mesh && mesh.drawRanges.has(rangeId) && !seen.has(dedupKey)) { + seen.add(dedupKey); + const range = mesh.drawRanges.get(rangeId)!; + entries.push({mesh, rangeId, triangles: Math.floor(range[1] / 3), density: 0, spike: 0, spikeTris: 0}); + } + } + if (Array.isArray(node?.children)) for (const c of node.children) visit(c); + }; + visit(treeData); + + // Tree may not cover every batched geom (e.g. FEA overlays with no + // tree rows) — fall back to scene order when it yields nothing. + return entries.length > 0 ? entries : sceneOrderEntries(meshes, false); +} + +export function collectGeomEntries( + order: WalkOrder, + thresholds: SpikeThresholds = DEFAULT_SPIKE_THRESHOLDS, +): GeomEntry[] { + const meshes = allBatchedMeshes(); + if (order === "tree") return treeOrderEntries(meshes); + const entries = sceneOrderEntries(meshes, order === "density" || order === "distorted", thresholds); + if (order === "density") entries.sort((x, y) => y.density - x.density); + if (order === "distorted") { + // Only geoms with an outlier-vertex spike, worst-first — the walk visits problems, not the + // whole model. Empty result ⇒ nothing distorted (the good outcome). The Mesh panel passes + // adjusted thresholds here to re-scan tighter/looser; the gallery uses the default. + return entries.filter((e) => e.spike > thresholds.spikeOutlierK).sort((x, y) => y.spike - x.spike); + } + return entries; +} + +// Hide every draw-range except the kept (mesh, rangeId). hideBatchDrawRange +// is additive-only, so reset with unhideAllRanges() first. +function hideAllExcept(keepMesh: CustomBatchedMesh, keepRangeId: string): void { + unhideAllRanges(); + for (const mesh of allBatchedMeshes()) { + const toHide: string[] = []; + for (const id of mesh.drawRanges.keys()) { + if (mesh === keepMesh && id === keepRangeId) continue; + toHide.push(id); + } + if (toHide.length) mesh.hideBatchDrawRange(toHide); + } + requestRender(); +} + +// Select the entry, populate the Object Info panel, optionally isolate +// it, and frame the camera on it (fit object). +// True only while the mesh is still attached to the live scene. A scope switch disposes the old +// scope's batched meshes; a walk holding stale entries would otherwise select/frame a freed mesh +// (disposed geometry -> crash). Guards every operation that touches a walked mesh. +function meshIsLive(mesh: CustomBatchedMesh | undefined | null): boolean { + if (!mesh) return false; + const scene = sceneRef.current; + if (!scene) return false; + let node: any = mesh; + while (node) { + if (node === scene) return true; + node = node.parent; + } + return false; +} + +export async function focusGeomEntry( + entry: GeomEntry | undefined, + opts: {hideUnselected: boolean; forceEdges?: boolean}, +): Promise { + // A scope/scene transition can leave a stale or disposed entry in the walk — never crash on it. + if (!entry || !meshIsLive(entry.mesh) || !entry.mesh.drawRanges.has(entry.rangeId)) return; + const {mesh, rangeId} = entry; + + // Inspecting spikes is pointless without triangle edges — turn them on for the distorted walk. + // (Geometry Edges default on; this re-asserts it. A user who loaded with edges off may need a + // reload for the overlay to attach — the toggle itself is reload-gated.) + if (opts.forceEdges && !useOptionsStore.getState().showEdges) { + useOptionsStore.getState().setShowEdges(true); + } + + const sel = useSelectedObjectStore.getState(); + sel.clearSelectedObjects(); + + if (opts.hideUnselected) hideAllExcept(mesh, rangeId); + else unhideAllRanges(); + + sel.addSelectedObject(mesh, rangeId); + + // Info panel: same fields the click handler populates. + const name = await queryNameFromRangeId(mesh.unique_key, rangeId); + const info = useObjectInfoStore.getState(); + info.setName(name); + const activeFile = useModelState.getState().loadedSourceName; + info.setFileName(activeFile ?? null); + info.setJsonData(null); + info.setFaceIndex(null); + if (name) { + try { + void query_ws_server_mesh_info(name, 0, activeFile); + } catch { + /* metadata is best-effort; selection + framing still work */ + } + } + + // The name lookup above awaited; a scope switch may have disposed the mesh in that window. + // Re-check before framing so centerViewOnSelection never reads freed geometry. + if (!meshIsLive(mesh)) return; + const controls = controlsRef.current; + const camera = cameraRef.current; + if (controls && camera) centerViewOnSelection(controls, camera, 1.5); + requestRender(); +} + +// Leave a geom walk cleanly: drop the isolation and the selection so the +// scene returns to its normal state (used when the walk type changes, +// gallery mode is turned off, or the scope is reloaded). +export function endGeomWalk(): void { + unhideAllRanges(); + useSelectedObjectStore.getState().clearSelectedObjects(); + requestRender(); +} diff --git a/src/frontend/src/utils/scene/handlers/view_in_3d.ts b/src/frontend/src/utils/scene/handlers/view_in_3d.ts index 6e5ba5550..cd2fb76d5 100644 --- a/src/frontend/src/utils/scene/handlers/view_in_3d.ts +++ b/src/frontend/src/utils/scene/handlers/view_in_3d.ts @@ -23,10 +23,14 @@ import {useOptionsStore} from "@/state/optionsStore"; export async function view_in_3d( sourceKey: string, derivedKey: string, + // Load the blob from this scope instead of the currently-selected one. + // The audit grid passes its run's scope (e.g. corpus:) so a cell's + // cached product opens regardless of which scope the user is browsing. + scopeOverride?: string, ): Promise { const inModal = useViewerPanelStore.getState().open !== null; const scope = useScopeStore.getState().current; - const scopePart = scope ? scopeUrlPart(scope) : ""; + const scopePart = scopeOverride ?? (scope ? scopeUrlPart(scope) : ""); if (!inModal) { const params = new URLSearchParams({ @@ -45,5 +49,5 @@ export async function view_in_3d( useOptionsStore.getState().setIsOptionsVisible(false); const {overlay_file_in_scene} = await import("./overlay_file_in_scene"); - await overlay_file_in_scene(sourceKey, derivedKey); + await overlay_file_in_scene(sourceKey, derivedKey, scopeOverride ? {scope: scopeOverride} : undefined); } diff --git a/src/frontend/src/utils/scene/refreshEdgeOverlays.ts b/src/frontend/src/utils/scene/refreshEdgeOverlays.ts new file mode 100644 index 000000000..f7e768f96 --- /dev/null +++ b/src/frontend/src/utils/scene/refreshEdgeOverlays.ts @@ -0,0 +1,33 @@ +import * as THREE from "three"; +import {sceneRef, rendererRef} from "@/state/refs"; +import {CustomBatchedMesh} from "@/utils/mesh_select/CustomBatchedMesh"; +import {useOptionsStore} from "@/state/optionsStore"; +import {requestRender} from "@/state/perfStore"; + +// Rebuild every design mesh's edge overlay in place — WITHOUT a page/model reload — so a change to +// `showEdges` (on/off) or `hideTessellationEdges` (feature-only vs full triangulation grid) takes +// effect immediately. Both are otherwise baked at model load (prepareLoadedModel builds the overlay +// once, reading hideTessellationEdges at that time), which is why the options drawer says "reload". +// +// For each eligible mesh (one that took an edge overlay at load — FEA streaming meshes never do): drop +// the stale overlay + its cached geometry, and when edges are on, re-add a freshly-built overlay +// (rebuilt from the CURRENT options). Preserves the overlay's layer (1) + parent so picking/masking is +// unchanged. NB: a live rebuild resets the edge material's per-range highlight/hide state — re-click to +// restore it if needed. +export function refreshEdgeOverlays(): void { + const scene = sceneRef.current; + const renderer = rendererRef.current; + if (!scene || !renderer) return; + const showEdges = useOptionsStore.getState().showEdges; + const meshes: CustomBatchedMesh[] = []; + scene.traverse((o) => { + if (o instanceof CustomBatchedMesh && o.edgesEligible) meshes.push(o); + }); + for (const mesh of meshes) { + const old = mesh.invalidateEdgeOverlay(); + const parent = old?.parent ?? (mesh.parent as THREE.Object3D | null); + old?.parent?.remove(old); + if (showEdges && parent && mesh.drawRanges.size) parent.add(mesh.getEdgeOverlay(renderer)); + } + requestRender(); +} diff --git a/src/frontend/src/utils/storage/fileTree.ts b/src/frontend/src/utils/storage/fileTree.ts index 16416d9c2..54203d3b2 100644 --- a/src/frontend/src/utils/storage/fileTree.ts +++ b/src/frontend/src/utils/storage/fileTree.ts @@ -98,6 +98,15 @@ export function buildFileTree( return root.children; } +// Short bullet list of keys for delete-confirmation dialogs — first +// ``max`` entries, then an "…and N more" line. Keeps the confirm() +// readable when the selection (or folder) holds hundreds of files. +export function previewKeyList(keys: readonly string[], max = 15): string { + const shown = keys.slice(0, max).map((k) => ` • ${k}`); + if (keys.length > max) shown.push(` …and ${keys.length - max} more`); + return shown.join("\n"); +} + // Persisted per-scope so expand state survives reloads but doesn't // leak across scopes. Caller passes a ``namespace`` (e.g. "storage" // for the regular browser, "admin-storage" for the admin table) so diff --git a/src/frontend/src/utils/websocket/initWebSocket.ts b/src/frontend/src/utils/websocket/initWebSocket.ts index 4a0bd2986..146437845 100644 --- a/src/frontend/src/utils/websocket/initWebSocket.ts +++ b/src/frontend/src/utils/websocket/initWebSocket.ts @@ -24,6 +24,13 @@ function pickConnectUrl(): string { * during ``initWebSocket`` (well before AuthGate's bootstrap * resolves). */ export function loadInitialServerState(): void { + // File listing goes out FIRST: it's what the storage browser is + // waiting on and the slowest server op (cold S3 list ~3s), so it + // must not queue behind server-info. requestServerInfo (process + // metadata + the worker-advertised set) is lower priority and can + // resolve after — the UI it feeds (convert matrix, tags) isn't on + // the critical "show me my files" path. + request_list_of_files_from_server(); requestServerInfo(); // LIST_WEB_CLIENTS is a websocket-era concept (per-connection // client tracking). The REST backend rejects the command with a @@ -32,7 +39,6 @@ export function loadInitialServerState(): void { if (!runtime.isRestMode()) { requestConnectedClients(); } - request_list_of_files_from_server(); } export async function initWebSocket() { diff --git a/tests/comms/rest/test_admin.py b/tests/comms/rest/test_admin.py index b08d16d2b..955427176 100644 --- a/tests/comms/rest/test_admin.py +++ b/tests/comms/rest/test_admin.py @@ -131,6 +131,38 @@ def test_admin_uuid_validation(tmp_path): assert r.status_code == 400 +@needs_postgres +def test_api_me_lists_corpus_scopes_for_admin_only(tmp_path): + """An admin's /api/me advertises corpus scopes (so corpus files can be + browsed + visualised from the main storage panel); a non-admin's does not.""" + settings = _settings(tmp_path, db_url=POSTGRES_URL) + slug = f"corp-{uuid.uuid4().hex[:12]}" + app = create_app(settings) + with TestClient(app) as client: + # local-dev synthetic user is admin — create a corpus, then read /api/me. + r = client.post("/api/admin/corpora", json={"slug": slug, "name": "Corp Test"}) + assert r.status_code == 201, r.text + me = client.get("/api/me").json() + corpus_scopes = [s for s in me["scopes"] if s["kind"] == "corpus"] + assert any(s["id"] == slug and s["name"] == "Corp Test" for s in corpus_scopes), corpus_scopes + client.delete(f"/api/admin/corpora/{slug}") + + # Non-admin: same DB, but no corpus scopes surface. + import ada.comms.rest.auth as _auth + + orig = _auth.User.local_dev + _auth.User.local_dev = classmethod( + lambda cls: cls(sub="viewer", email="v@x", display_name="V", groups=frozenset(), is_admin=False) + ) + try: + app2 = create_app(settings) + with TestClient(app2) as client: + me = client.get("/api/me").json() + assert not any(s["kind"] == "corpus" for s in me["scopes"]), me["scopes"] + finally: + _auth.User.local_dev = orig + + # ── Live-Postgres path ─────────────────────────────────────────────── diff --git a/tests/comms/rest/test_audit_run_routes.py b/tests/comms/rest/test_audit_run_routes.py index 18c2b5a11..662710055 100644 --- a/tests/comms/rest/test_audit_run_routes.py +++ b/tests/comms/rest/test_audit_run_routes.py @@ -143,6 +143,66 @@ def test_extend_folds_idle_gap(db): assert 3_400_000 < after["idle_ms"] < 3_800_000 +def test_cells_duration_ms_is_sum_of_cells(db): + pool, run = db + r = run(db_module.create_audit_run(pool, scope="shared", worker_pool=None)) + run(db_module.set_audit_run_total(pool, r["id"], 2)) + run(db_module.insert_audit(pool, user_sub=None, scope_kind="shared", scope_id=None, action="convert", + key="a.step", target_format="glb", status="done", duration_ms=10, + job_id="jobA", audit_run_id=r["id"])) + run(db_module.insert_audit(pool, user_sub=None, scope_kind="shared", scope_id=None, action="convert", + key="b.step", target_format="glb", status="error", duration_ms=20, + job_id="jobB", audit_run_id=r["id"])) + after = run(db_module.get_audit_run(pool, r["id"])) + assert after["cells_duration_ms"] == 30 # sum, not wall clock + assert after["ok"] == 1 and after["failed"] == 1 and after["status"] == "finished" + + +def test_reset_audit_cell_for_rerun_undoes_counter_and_reopens(db): + pool, run = db + r = run(db_module.create_audit_run(pool, scope="shared", worker_pool=None)) + run(db_module.set_audit_run_total(pool, r["id"], 2)) + run(db_module.insert_audit(pool, user_sub=None, scope_kind="shared", scope_id=None, action="convert", + key="a.step", target_format="glb", status="done", duration_ms=10, + job_id="jobA", audit_run_id=r["id"])) + run(db_module.insert_audit(pool, user_sub=None, scope_kind="shared", scope_id=None, action="convert", + key="b.step", target_format="glb", status="error", duration_ms=20, + job_id="jobB", audit_run_id=r["id"])) + + # Re-run cell B: the failed counter drops, the run reopens, and B's row is + # re-pointed at a fresh job with its result timing cleared. + ok = run(db_module.reset_audit_cell_for_rerun(pool, r["id"], "b.step", "glb", "jobB2")) + assert ok is True + mid = run(db_module.get_audit_run(pool, r["id"])) + assert mid["failed"] == 0 and mid["ok"] == 1 + assert mid["status"] == "running" and mid["finished_at"] is None + assert mid["cells_duration_ms"] == 10 # B's 20ms cleared until it re-completes + + # The worker completes the new job → B goes green, run re-finishes, runtime + # reflects the new per-cell timing. + run(db_module.update_audit_by_job(pool, job_id="jobB2", status="done", duration_ms=15)) + end = run(db_module.get_audit_run(pool, r["id"])) + assert end["ok"] == 2 and end["failed"] == 0 + assert end["status"] == "finished" + assert end["cells_duration_ms"] == 25 # 10 + the re-run's 15 + + # Unknown cell → no-op, returns False. + assert run(db_module.reset_audit_cell_for_rerun(pool, r["id"], "nope.step", "glb", "x")) is False + + +def test_reset_audit_cell_folds_idle_gap(db): + pool, run = db + r = run(_finish_run(pool, n=1)) # one done cell, run finished + # Backdate the finish so the re-run sees a ~1h gap since the original run. + run(pool.execute("UPDATE audit_runs SET finished_at = NOW() - INTERVAL '1 hour' WHERE id = $1", r["id"])) + run(db_module.reset_audit_cell_for_rerun(pool, r["id"], "models/f0.step", "glb", "job0b")) + after = run(db_module.get_audit_run(pool, r["id"])) + assert after["status"] == "running" and after["finished_at"] is None + # The ~1h gap is folded into idle_ms so wall clock won't swallow it when the + # cell re-completes — the re-run only adds its own delta. + assert 3_400_000 < after["idle_ms"] < 3_800_000 + + def test_claim_run_for_validation_is_once(db): pool, run = db r = run(_finish_run(pool, n=1)) @@ -167,6 +227,80 @@ def test_claim_auto_validate_only_flagged_runs(db): assert run(db_module.claim_audit_run_for_auto_validate(pool)) is None +def _close_cell(p, run_id, *, key, target_format="glb", action="convert"): + return db_module.insert_audit( + p, + user_sub=None, + scope_kind="shared", + scope_id=None, + action=action, + key=key, + target_format=target_format, + status="done", + duration_ms=10, + audit_run_id=run_id, + ) + + +def test_reserved_validation_counts_upfront(db): + """An auto-validate run advertises conversions + parity in ``total`` from + the start; the validation dispatch consumes the reservation instead of + growing the total.""" + pool, run = db + r = run(db_module.create_audit_run(pool, scope="shared", worker_pool=None, auto_validate=True)) + # 2 conversion cells + 1 reserved parity cell. + run(db_module.set_audit_run_total(pool, r["id"], 3, validate_total=1)) + fresh = run(db_module.get_audit_run(pool, r["id"])) + assert fresh["total"] == 3 and fresh["validate_total"] == 1 + + # Not claimable while conversion cells are still outstanding. + run(_close_cell(pool, r["id"], key="models/f0.step")) + assert run(db_module.claim_audit_run_for_auto_validate(pool)) is None + + # All conversion cells landed: the reserve keeps the run 'running', + # and the poller can now claim it for validation. + run(_close_cell(pool, r["id"], key="models/f1.step")) + mid = run(db_module.get_audit_run(pool, r["id"])) + assert mid["status"] == "running" and mid["finished_at"] is None + claimed = run(db_module.claim_audit_run_for_auto_validate(pool)) + assert claimed is not None and claimed["id"] == r["id"] + + # Dispatch swaps the reservation for the actual parity count — total unchanged. + run(db_module.consume_audit_run_validation_reserve(pool, r["id"], 1)) + consumed = run(db_module.get_audit_run(pool, r["id"])) + assert consumed["total"] == 3 and consumed["validate_total"] == 0 + assert consumed["status"] == "running" + assert consumed["idle_ms"] == 0 # never finished, so no idle gap folded in + + # Parity cell lands → run finishes at the originally-advertised total. + run(_close_cell(pool, r["id"], key="models/f0.step", target_format="parity", action="validate")) + done = run(db_module.get_audit_run(pool, r["id"])) + assert done["status"] == "finished" and done["total"] == 3 + + +def test_consume_reserve_handles_drift_and_zero(db): + """Scope drift between the two enumerations moves the total by the + difference; zero actual parity cells finishes the run on the spot.""" + pool, run = db + # Drift up: reserved 1, actual 2. + a = run(db_module.create_audit_run(pool, scope="shared", worker_pool=None, auto_validate=True)) + run(db_module.set_audit_run_total(pool, a["id"], 2, validate_total=1)) + run(_close_cell(pool, a["id"], key="models/f0.step")) + run(db_module.consume_audit_run_validation_reserve(pool, a["id"], 2)) + drifted = run(db_module.get_audit_run(pool, a["id"])) + assert drifted["total"] == 3 and drifted["validate_total"] == 0 + assert drifted["status"] == "running" + + # Zero actual: reservation released, run finishes (no bump will arrive). + b = run(db_module.create_audit_run(pool, scope="shared", worker_pool=None, auto_validate=True)) + run(db_module.set_audit_run_total(pool, b["id"], 2, validate_total=1)) + run(_close_cell(pool, b["id"], key="models/f0.step")) + run(db_module.consume_audit_run_validation_reserve(pool, b["id"], 0)) + released = run(db_module.get_audit_run(pool, b["id"])) + assert released["total"] == 1 and released["validate_total"] == 0 + assert released["status"] == "finished" and released["finished_at"] is not None + + def test_delete_audit_run_removes_log_rows(db): pool, run = db r = run(_finish_run(pool, n=2)) diff --git a/tests/comms/rest/test_converter_step_routing.py b/tests/comms/rest/test_converter_step_routing.py index 7e8807781..2d51e2162 100644 --- a/tests/comms/rest/test_converter_step_routing.py +++ b/tests/comms/rest/test_converter_step_routing.py @@ -62,3 +62,84 @@ def test_glb_target_exposes_tessellation_quality_options(): assert {"tess_linear_deflection", "tess_angular_deg", "tess_relative"} <= names # and they are in the global allowlist used by the API validator assert "tess_linear_deflection" in conv.ConverterRegistry.all_options() + + +def _serializer_tessellator(source_ext: str): + opts = conv.ConverterRegistry.options_for(source_ext, "glb") + ser = next(o for o in opts if o["name"] == "serializer") + tess = next(o for o in opts if o["name"] == "tessellator") + return ser, tess + + +def test_glb_serializer_tessellator_advertised_single_source(): + """The reconvert dropdowns are data-driven: every → GLB row advertises a + serializer enum with labels + a client/server runtime split, and a + dependent tessellator enum keyed by serializer (enum_by). The frontend + renders straight from this — no hardcoded vocabulary.""" + for ext in (".step", ".ifc", ".sat"): + ser, tess = _serializer_tessellator(ext) + assert ser["type"] == "enum" and tess["type"] == "enum" + assert ser["default"] == "cpp" + assert set(ser["enum"]) == {"cpp", "python", "wasm"} + # labels + runtime per serializer value + assert set(ser["labels"]) == set(ser["enum"]) + assert ser["runtime"]["wasm"] == "client" + assert ser["runtime"]["cpp"] == "server" and ser["runtime"]["python"] == "server" + # dependent tessellator: enum_by keyed by serializer, depends_on wired + assert tess["depends_on"] == "serializer" + assert set(tess["enum_by"]) == set(ser["enum"]) + assert tess["enum_by"]["cpp"] == ["native"] + assert "pyocc" in tess["enum_by"]["python"] and "cgal" in tess["enum_by"]["python"] + assert tess["enum_by"]["wasm"] == ["wasm-native", "pyodide"] + # STEP exposes the OCC streaming reader as an extra python kernel; generic does not. + step_tess = _serializer_tessellator(".step")[1] + ifc_tess = _serializer_tessellator(".ifc")[1] + assert "occ" in step_tess["enum_by"]["python"] + assert "occ" not in ifc_tess["enum_by"]["python"] + # new names are in the API allowlist + assert {"serializer", "tessellator"} <= conv.ConverterRegistry.all_options() + + +def test_apply_glb_serializer_resolves_to_engine_knobs(): + """The serializer/tessellator tokens fold into the existing engine knobs; + unset tokens leave explicit knobs untouched (full back-compat).""" + ap = conv._apply_glb_serializer + # cpp/STEP -> adacpp-native pipeline, server path + assert ap(".step", "cpp", None, step_glb_pipeline=None, glb_tess_engine=None) == ( + conv._STEP_GLB_PIPELINE_ADACPP_NATIVE, None, False) + # python/STEP kernels map to the STEP pipeline + force the python path + assert ap(".step", "python", "pyocc", step_glb_pipeline=None, glb_tess_engine=None) == ( + conv._STEP_GLB_PIPELINE_OCC, None, True) + assert ap(".step", "python", "cgal", step_glb_pipeline=None, glb_tess_engine=None) == ( + conv._STEP_GLB_PIPELINE_ADACPP_CGAL, None, True) + # python/IFC maps to the BatchTessellator engine + forces python (bypass native ifc->glb) + assert ap(".ifc", "python", "ifc-hybrid", step_glb_pipeline=None, glb_tess_engine=None) == ( + None, conv._STEP_GLB_PIPELINE_ADACPP_HYBRID, True) + # client serializer never resolves server-side (routed in-browser by the SPA) + assert ap(".ifc", "wasm", "pyodide", step_glb_pipeline=None, glb_tess_engine=None) == (None, None, False) + # unset serializer keeps explicit knobs + defaults + assert ap(".step", None, None, step_glb_pipeline="occ-builtin", glb_tess_engine=None) == ( + "occ-builtin", None, False) + + +def test_brep_target_exposes_serializer_writer_axis(): + """B-rep→B-rep rows (step→ifc, ifc→step) advertise the same shared selector, but the 2nd axis is + titled 'Writer' (no tessellation) and mirrors the serializer 1:1.""" + for frm, to in ((".step", "ifc"), (".ifc", "step")): + opts = conv.ConverterRegistry.options_for(frm, to) + by_name = {o["name"]: o for o in opts} + # not every build registers native ifc→step; skip when the path options aren't present + if "serializer" not in by_name: + continue + ser, wr = by_name["serializer"], by_name["tessellator"] + assert ser["title"] == "Serializer" + assert set(ser["enum"]) == {"cpp", "python", "wasm"} + assert ser["runtime"]["wasm"] == "client" + # 2nd axis is DISPLAYED as Writer (wire key stays 'tessellator' for a unified resolver) + assert wr["title"] == "Writer" + assert wr["depends_on"] == "serializer" + assert wr["enum_by"] == {"cpp": ["native"], "python": ["occ"], "wasm": ["wasm-native"]} + # python serializer → the OCC writer branch; cpp/wasm do not + assert conv._brep_writer_is_python("python") is True + assert conv._brep_writer_is_python("cpp") is False + assert conv._brep_writer_is_python("wasm") is False diff --git a/tests/comms/rest/test_reconvert_key.py b/tests/comms/rest/test_reconvert_key.py new file mode 100644 index 000000000..bf5432613 --- /dev/null +++ b/tests/comms/rest/test_reconvert_key.py @@ -0,0 +1,51 @@ +"""The gallery "Re-convert" output lives in a separate ``_reconvert/`` namespace so it never +overwrites a corpus scope's ``_derived/`` audit product.""" + +import pytest + +from ada.comms.rest.converter import ( + UnsupportedFormat, + derived_key_for, + is_derived_key, + is_hidden_key, + reconvert_key_for, +) + + +def test_reconvert_key_is_separate_from_derived(): + src = "cad/ifc/beam-standard-case.ifc" + assert reconvert_key_for(src, "glb") == "_reconvert/cad/ifc/beam-standard-case.ifc.glb" + # Must NOT collide with the audit-run derived product. + assert reconvert_key_for(src, "glb") != derived_key_for(src, "glb") + + +def test_reconvert_key_is_hidden_but_not_derived(): + key = reconvert_key_for("x/y.ifc", "glb") + # Hidden from the file explorer (throwaway, not a user file)... + assert is_hidden_key(key) is True + # ...but NOT a "derived product" — derived-product logic (rename/cleanup/grouping) must not + # touch it, and the audit ``_derived/`` product stays the canonical one. + assert is_derived_key(key) is False + + +def test_reconvert_key_rejects_unknown_format(): + with pytest.raises(UnsupportedFormat): + reconvert_key_for("x/y.ifc", "bogus") + + +def test_reconvert_and_overlay_excluded_from_audit_cells(): + """Audit cell enumeration skips ``is_hidden_key`` (not just ``is_derived_key``). A re-convert + or overlay blob is stored as ``.glb`` — a SUPPORTED source ext — and ``_reconvert/`` is + deliberately NOT a derived key, so without the hidden-key skip both would leak in as audit + cells. Assert the exact filter the dispatcher uses excludes them.""" + from ada.comms.rest.converter import is_supported_source + + reconvert = "_reconvert/cad/ifc/beam-extruded-solid.ifc.glb" + overlay = "_overlays/mymodel.merge.glb" + real_source = "cad/ifc/beam-extruded-solid.ifc" + + # Both throwaway blobs pass the source-ext test (glb) but must be hidden... + assert is_supported_source(reconvert) and is_hidden_key(reconvert) + assert is_supported_source(overlay) and is_hidden_key(overlay) + # ...while a genuine corpus source is a source and NOT hidden -> becomes a cell. + assert is_supported_source(real_source) and not is_hidden_key(real_source) diff --git a/tests/core/api/shapes/__init__.py b/tests/core/api/shapes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/core/api/shapes/test_shape_store.py b/tests/core/api/shapes/test_shape_store.py new file mode 100644 index 000000000..12d555e20 --- /dev/null +++ b/tests/core/api/shapes/test_shape_store.py @@ -0,0 +1,327 @@ +"""ShapeStore + ShapeProxy: blob-backed lazy shape geometry. + +Parity is asserted via re-serialization bytes (NGEOM) or field checks — Geometry +dataclass ``==`` is unusable because Point's ndarray ``__eq__`` breaks dataclass +equality. +""" + +from __future__ import annotations + +import gc +import pickle +import weakref + +import ada.geom.curves as cu +import ada.geom.solids as so +import ada.geom.surfaces as su +from ada.api.primitives.base import Shape +from ada.api.shapes import ShapeProxy, ShapeStore +from ada.cadit.ngeom import serialize_geometries +from ada.geom.booleans import BooleanOperation, BoolOpEnum +from ada.geom.core import Geometry +from ada.geom.placement import Axis2Placement3D, Direction, Point + + +def _line_oe(s, t): + ec = cu.EdgeCurve(start=s, end=t, edge_geometry=cu.Line(s, [b - a for a, b in zip(s, t)]), same_sense=True) + return cu.OrientedEdge(start=s, end=t, edge_element=ec, orientation=True) + + +def _square_face(): + p = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)] + loop = cu.EdgeLoop(edge_list=[_line_oe(p[i], p[(i + 1) % 4]) for i in range(4)]) + plane = su.Plane(position=Axis2Placement3D(Point(0, 0, 0), Direction(0, 0, 1), Direction(1, 0, 0))) + return su.FaceSurface(bounds=[su.FaceBound(bound=loop, orientation=True)], face_surface=plane, same_sense=True) + + +def _shell_geometry(gid="solid1") -> Geometry: + return Geometry(id=gid, geometry=su.ClosedShell(cfs_faces=[_square_face()])) + + +def _boolean_geometry(gid="cut1") -> Geometry: + base = _shell_geometry(gid) + half_space = su.HalfSpaceSolid( + base_surface=su.Plane(position=Axis2Placement3D(Point(0, 0, 1), Direction(0, 0, 1), Direction(1, 0, 0))) + ) + box = so.Box(Axis2Placement3D(Point(0, 0, 0), Direction(0, 0, 1), Direction(1, 0, 0)), 1.0, 1.0, 1.0) + base.bool_operations = [ + BooleanOperation(Geometry("hs", half_space), BoolOpEnum.DIFFERENCE), + BooleanOperation(Geometry("box", box), BoolOpEnum.UNION), + ] + return base + + +def _ngeom_blob(geom: Geometry) -> bytes: + return serialize_geometries([(str(geom.id), geom.geometry)]) + + +def test_ngeom_blob_roundtrip_byte_parity(): + """ngeom-kind: hydrated tree re-serializes to the exact stored bytes.""" + g = _shell_geometry() + blob = _ngeom_blob(g) + store = ShapeStore() + idx = store.add_blob(blob, gid=str(g.id)) + hydrated = store.geometry(idx) + assert hydrated.id == "solid1" + assert serialize_geometries([(hydrated.id, hydrated.geometry)]) == blob + + +def test_add_blob_rejects_non_ngeom(): + store = ShapeStore() + try: + store.add_blob(b"not a buffer at all", gid="x") + except ValueError as e: + assert "magic" in str(e) + else: + raise AssertionError("expected ValueError on bad magic") + + +def test_pickle_kind_roundtrips_bool_operations(): + """pickle-kind: booleans (incl. half-space operands) hydrate exactly.""" + g = _boolean_geometry() + store = ShapeStore() + idx = store.add_geometry(g) + hydrated = store.geometry(idx) + assert hydrated.id == "cut1" + assert isinstance(hydrated.geometry, su.ClosedShell) + ops = hydrated.bool_operations + assert [op.operator for op in ops] == [BoolOpEnum.DIFFERENCE, BoolOpEnum.UNION] + hs = ops[0].second_operand.geometry + assert isinstance(hs, su.HalfSpaceSolid) + assert hs.agreement_flag is True + assert list(hs.base_surface.position.location) == [0.0, 0.0, 1.0] + box = ops[1].second_operand.geometry + assert isinstance(box, so.Box) + assert (box.x_length, box.y_length, box.z_length) == (1.0, 1.0, 1.0) + + +def test_weakref_cache_identity_and_release(): + # hydration_cache_size=0 -> pure weakref semantics (no strong LRU window) + store = ShapeStore(hydration_cache_size=0) + idx = store.add_geometry(_shell_geometry()) + g1 = store.geometry(idx) + assert store.geometry(idx) is g1, "same live object while referenced" + ref = weakref.ref(g1) + del g1 + gc.collect() + assert ref() is None, "hydrated tree must be reclaimable once dropped" + # and a fresh access hydrates again + assert store.geometry(idx).id == "solid1" + + +def test_hydration_lru_keeps_hot_and_evicts_cold(): + """Back-to-back .geom access must not re-decode (consumers read it 3-5x per + call), while the strong window stays bounded — hydrate-all still returns to + the blob floor.""" + store = ShapeStore(hydration_cache_size=2) + idxs = [store.add_geometry(_shell_geometry(f"s{i}")) for i in range(4)] + + g0 = store.geometry(idxs[0]) + assert store.geometry(idxs[0]) is g0, "hot entry must be identity-stable with no outside refs" + + ref0 = weakref.ref(g0) + del g0 + for i in idxs[1:]: # push idx0 out of the 2-slot window + store.geometry(i) + gc.collect() + assert ref0() is None, "evicted entry must be reclaimable" + + +def test_compression_roundtrip_both_kinds(): + g = _shell_geometry() + blob = _ngeom_blob(g) + store = ShapeStore(compress=True) + i_ngeom = store.add_blob(blob, gid=str(g.id)) + i_pickle = store.add_geometry(_boolean_geometry()) + assert store.record(i_ngeom).compressed and store.record(i_pickle).compressed + assert store.nbytes < len(blob) + 100_000 # stored compressed + assert store.ngeom_blob(i_ngeom) == blob # decompresses to the original + assert store.geometry(i_pickle).bool_operations[0].operator == BoolOpEnum.DIFFERENCE + + +def test_proxy_is_shape_and_hydrates_via_property(): + store = ShapeStore() + g = _shell_geometry() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + p = ShapeProxy("solid1", store, idx) + assert isinstance(p, Shape) + assert p._geom is None, "proxy must not hold an eager tree" + # NGEOM lowers ClosedShell -> ConnectedFaceSet; today's eager native reader + # yields the same, so downstream behaviour is identical. + assert isinstance(p.geom.geometry, su.ConnectedFaceSet) + assert p.ngeom_blob() is not None + + # solid_geom() (a base-class method) works through the overridden property on + # an accepted solid type — pickle-kind keeps the ClosedShell exact. + idx2 = store.add_geometry(_shell_geometry("solid2")) + p2 = ShapeProxy("solid2", store, idx2) + solid = p2.solid_geom() + assert solid.id == "solid2" + assert isinstance(solid.geometry, su.ClosedShell) + + +def test_proxy_pin_semantics(): + # hydration_cache_size=0: the strong LRU window would keep an unpinned mutation + # alive until eviction — the CONTRACT (pin before mutating) is what's tested. + store = ShapeStore(hydration_cache_size=0) + idx = store.add_geometry(_shell_geometry()) + p = ShapeProxy("solid1", store, idx) + + # unpinned: mutation on a transient hydration does not survive a GC cycle + p.geom.color = "marker" + gc.collect() + assert p.geom.color is None + + pinned = p.pin() + pinned.color = "marker" + gc.collect() + assert p.geom.color == "marker" + + # assigning .geom pins the assigned object + other = _shell_geometry("other") + p.geom = other + assert p.geom is other + + +def test_proxy_pickle_shares_store(): + store = ShapeStore() + g = _shell_geometry() + blob = _ngeom_blob(g) + idx1 = store.add_blob(blob, gid="a") + idx2 = store.add_blob(blob, gid="b") + p1 = ShapeProxy("a", store, idx1) + p2 = ShapeProxy("b", store, idx2) + r1, r2 = pickle.loads(pickle.dumps([p1, p2])) + assert r1._shape_store is r2._shape_store, "pickle memo must keep one store" + assert r1.geom.id == "a" and r2.geom.id == "b" + assert isinstance(r1, ShapeProxy) and isinstance(r1, Shape) + + +def test_blob_fast_path_matches_serialized_tessellation(): + """A stored NGEOM blob tessellates via tessellate_stream_buffer to the same mesh + the hydrate+re-serialize route produces (the lazy fast path skips both steps).""" + import pytest + + from ada.cad import active_backend + + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer") or not hasattr(be, "tessellate_stream"): + pytest.skip("active CAD backend has no NGEOM stream tessellation") + + g = _shell_geometry() + store = ShapeStore() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + p = ShapeProxy("solid1", store, idx) + + via_blob = be.tessellate_stream_buffer(p.ngeom_blob(), pipeline="libtess2") + hydrated = p.geom + via_items = be.tessellate_stream([(str(hydrated.id), hydrated.geometry)], pipeline="libtess2") + assert via_blob.positions.tobytes() == via_items.positions.tobytes() + assert via_blob.indices.tobytes() == via_items.indices.tobytes() + + +def test_pickle_kind_has_no_ngeom_blob(): + store = ShapeStore() + idx = store.add_geometry(_boolean_geometry()) + assert store.ngeom_blob(idx) is None + p = ShapeProxy("cut1", store, idx) + assert p.ngeom_blob() is None + + +def test_stream_tessellation_applies_bool_operations(): + """A Geometry wrapper's bool_operations reach the stream kernel: the serializer + folds them into a BOOLEAN_RESULT chain (half-space lowered to a finite box, the + same lowering adacpp's readers use) and Manifold evaluates the cut.""" + import pytest + + import ada + from ada.cad import active_backend + + be = active_backend() + if not hasattr(be, "tessellate_stream"): + pytest.skip("active CAD backend has no NGEOM stream tessellation") + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + box.add_boolean(ada.BoolHalfSpace((0.5, 0.5, 0.6), (0, 0, 1), name="cut")) + geom = box.solid_geom() + assert geom.bool_operations, "fixture must carry a boolean" + + bm = be.tessellate_stream([("bx", geom)], pipeline="libtess2") + z = bm.positions.reshape(-1, 3)[:, 2] if bm.positions.ndim == 1 else bm.positions[:, 2] + assert len(z) > 0, "boolean-bearing solid tessellated to nothing" + zmax = float(z.max()) + assert abs(zmax - 0.6) < 1e-6, f"half-space cut not applied (zmax={zmax}, expected 0.6)" + + # solid second operand (UNION): a box fused on top raises the extent instead + box2 = ada.PrimBox("bx2", (0, 0, 0), (1, 1, 1)) + box2.add_boolean(ada.PrimBox("cap", (0.25, 0.25, 0.5), (0.75, 0.75, 1.4)), "union") + g2 = box2.solid_geom() + bm2 = be.tessellate_stream([("bx2", g2)], pipeline="libtess2") + z2 = bm2.positions.reshape(-1, 3)[:, 2] if bm2.positions.ndim == 1 else bm2.positions[:, 2] + assert abs(float(z2.max()) - 1.4) < 1e-6, "union operand not applied" + + +def test_backend_builds_hydrated_connected_face_set(): + """Audit regression: a non-promoted native root hydrates as bare ConnectedFaceSet + and must build on the ACTIVE backend (adacpp raised 'not yet ported'); it sews + like the ClosedShell/OpenShell shells.""" + from ada.cad import active_backend + + store = ShapeStore() + g = _shell_geometry() + idx = store.add_blob(_ngeom_blob(g), gid=str(g.id)) + hydrated = store.geometry(idx) + assert isinstance(hydrated.geometry, su.ConnectedFaceSet) # single face -> not promoted + handle = active_backend().build(hydrated) + assert handle is not None + + +def test_native_ifc_brep_products_import_as_ngeom_blobs(tmp_path): + """B-rep IFC products the Python-native readers can't resolve import via adacpp's + IfcNgeomStream as zero-copy ngeom-kind proxies instead of eager OCC kernel bodies.""" + import pytest + + import ada + + adacpp = pytest.importorskip("adacpp") + if not hasattr(adacpp.cad, "IfcNgeomStream"): + pytest.skip("adacpp build predates IfcNgeomStream") + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + a = ada.Assembly("m") / (ada.Part("p") / [box]) + stp = tmp_path / "nb.stp" + ifc = tmp_path / "nb.ifc" + a.to_stp(stp, writer="stream") + adacpp.cad.stream_step_to_ifc(str(stp), str(ifc)) # advanced-brep IFC4 + + b = ada.from_ifc(ifc) + shapes = [s for p in b.get_all_parts_in_assembly(include_self=True) for s in p.shapes] + assert shapes + for s in shapes: + assert isinstance(s, ShapeProxy), f"{s.name}: expected lazy proxy, got {type(s).__name__}" + assert s._occ_cache is None, f"{s.name}: eager OCC body retained" + rec = s._shape_store.record(s._store_index) + assert rec.kind == "ngeom", f"{s.name}: expected native blob, got {rec.kind}" + assert s.ngeom_blob() is not None + assert s.geom.geometry is not None # hydrates + + +def test_ifc_roundtrip_imports_lazy_proxies_with_booleans(tmp_path): + """from_ifc mints ShapeProxy objects (lazy store default-on) and a boolean cut + (IfcBooleanClippingResult -> bool_operations) survives the store round-trip.""" + import ada + + box = ada.PrimBox("bx", (0, 0, 0), (1, 1, 1)) + box.add_boolean(ada.BoolHalfSpace((0.5, 0.5, 0.9), (0, 0, 1), name="cut")) + a = ada.Assembly("m") / (ada.Part("p") / [box]) + f = tmp_path / "lazy_bool.ifc" + a.to_ifc(f) + + b = ada.from_ifc(f) + shapes = [s for p in b.get_all_parts_in_assembly(include_self=True) for s in p.shapes] + assert shapes, "no shapes imported" + proxies = [s for s in shapes if isinstance(s, ShapeProxy)] + assert proxies, f"expected lazy proxies, got {[type(s).__name__ for s in shapes]}" + geom = proxies[0].geom + assert geom.bool_operations, "boolean clipping lost through the lazy store" + assert geom.bool_operations[0].operator == BoolOpEnum.DIFFERENCE diff --git a/tests/core/api/test_placement_convention.py b/tests/core/api/test_placement_convention.py new file mode 100644 index 000000000..f6ba98ebe --- /dev/null +++ b/tests/core/api/test_placement_convention.py @@ -0,0 +1,63 @@ +"""Placement rotation-convention self-consistency. + +Historically ``from_4x4_matrix`` extracted the local axes from the matrix COLUMNS while every +other constructor (``from_axis_angle`` / ``from_quaternion`` / ``rotate`` / +``get_absolute_placement``) and ``rot_matrix`` used ROWS — so ``from_4x4_matrix`` produced the +TRANSPOSE (== inverse for a rotation), and a rotated IFC ObjectPlacement rendered on the wrong +world axis. These tests pin the invariants that keep the class consistent. +""" + +import numpy as np + +from ada import Placement + +# A genuinely asymmetric rotation: 90deg about Z. Standard world matrix M has COLUMNS equal to +# the world direction of each local axis, so M @ (1,0,0,1) sends local +X to world +Y. +_R = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) +_ORIGIN = np.array([5.0, 2.0, 0.0]) +_M = np.eye(4) +_M[:3, :3] = _R +_M[:3, 3] = _ORIGIN + + +def _M_at(local): + """Ground-truth world point: the standard homogeneous transform applied to a local point.""" + return (_M @ np.array([local[0], local[1], local[2], 1.0]))[:3] + + +def test_from_4x4_matrix_round_trips_through_get_matrix4x4(): + """from_4x4_matrix(M).get_matrix4x4() must equal M (it used to return M.T).""" + p = Placement.from_4x4_matrix(_M) + assert np.allclose(p.get_matrix4x4(), _M) + + +def test_from_4x4_matrix_transforms_local_axis_to_world_direction(): + """local +X must map to M's column-0 world direction (+Y here), not its negation.""" + p = Placement.from_4x4_matrix(_M) + assert np.allclose(p.transform_local_points_to_global(np.array([[1.0, 0, 0]]))[0], _M_at((1, 0, 0))) + assert np.allclose(p.transform_local_points_to_global(np.array([[0.0, 1, 0]]))[0], _M_at((0, 1, 0))) + + +def test_transform_methods_agree_with_get_matrix4x4(): + """The transform_* helpers and the 4x4 matrix must describe the SAME transform.""" + from ada import Placement as _P + + p = Placement.from_4x4_matrix(_M) + for local in [(1, 0, 0), (0, 1, 0), (0, 0, 1), (0.3, -0.7, 1.2)]: + truth = _M_at(local) + via_pts = p.transform_local_points_to_global(np.array([[float(local[0]), local[1], local[2]]]))[0] + via_other = p.transform_array_from_other_place( + np.array([[float(local[0]), local[1], local[2]]]), _P() + )[0] + via_mat = (p.get_matrix4x4() @ np.array([local[0], local[1], local[2], 1.0]))[:3] + assert np.allclose(via_pts, truth), f"transform_local_points_to_global {local}" + assert np.allclose(via_other, truth), f"transform_array_from_other_place {local}" + assert np.allclose(via_mat, truth), f"get_matrix4x4 {local}" + + +def test_from_4x4_matrix_matches_from_axis_angle(): + """from_4x4_matrix of a 90deg-about-Z world matrix must equal from_axis_angle(Z, 90) — the + two construction paths must produce the same rotation, not transposes of each other.""" + p_mat = Placement.from_4x4_matrix(_M) + p_aa = Placement.from_axis_angle([0, 0, 1], 90, origin=_ORIGIN) + assert np.allclose(np.array(p_mat.rot_matrix), np.array(p_aa.rot_matrix)) diff --git a/tests/core/api/test_shape_placement_bake.py b/tests/core/api/test_shape_placement_bake.py new file mode 100644 index 000000000..f3dd3ed15 --- /dev/null +++ b/tests/core/api/test_shape_placement_bake.py @@ -0,0 +1,100 @@ +"""The generic-Shape placement baker (``Shape.solid_geom`` folds a non-identity placement +into the analytic solid's ``position``). + +A generic ``Shape`` (as minted by the IFC/native readers) holds its geometry in LOCAL +representation coordinates and its world transform on ``self.placement``. The tessellator and +the STEP/AP242 exporters are placement-agnostic for such shapes — nothing applies the shape's +own placement downstream — so ``solid_geom()`` has to return world-placed geometry, otherwise +a rotated shape renders with its rotation dropped. These tests pin that the bake is applied, +is correct (matches the Placement 4x4 convention), and is idempotent (never mutates / never +compounds on repeat calls). +""" + +import numpy as np + +from ada import Placement, Point, Shape +from ada.geom import Geometry +from ada.geom.solids import Box + + +def _box_shape(place: Placement) -> Shape: + box = Box.from_2points(Point(0, 0, 0), Point(2, 1, 1)) + return Shape("boxshape", geom=Geometry("b", box, None), placement=place) + + +def test_identity_placement_leaves_geometry_untouched(): + sh = _box_shape(Placement()) + sg = sh.solid_geom() + # Same object, position unchanged at the local origin. + assert np.allclose(np.asarray(sg.geometry.position.location), (0, 0, 0)) + + +def test_rotated_placement_is_baked_into_position(): + # 90deg about Z at origin (5, 2, 0): local +X -> world +Y, translation applied. + place = Placement.from_axis_angle([0, 0, 1], 90, origin=(5, 2, 0)) + sh = _box_shape(place) + pos = sh.solid_geom().geometry.position + + loc_expected = (place.get_matrix4x4() @ np.array([0.0, 0, 0, 1]))[:3] + assert np.allclose(np.asarray(pos.location), loc_expected) + assert np.allclose(np.asarray(pos.ref_direction), (0, 1, 0), atol=1e-6) # local X -> world Y + assert np.allclose(np.asarray(pos.axis), (0, 0, 1), atol=1e-6) # Z preserved under a Z-rotation + + +def test_bake_is_idempotent_and_non_mutating(): + place = Placement.from_axis_angle([0, 0, 1], 90, origin=(5, 2, 0)) + sh = _box_shape(place) + loc_expected = (place.get_matrix4x4() @ np.array([0.0, 0, 0, 1]))[:3] + + first = sh.solid_geom().geometry.position.location + second = sh.solid_geom().geometry.position.location + assert np.allclose(np.asarray(first), loc_expected) + assert np.allclose(np.asarray(second), loc_expected) # not compounded + # The stored geometry (local) is never mutated by the bake. + assert np.allclose(np.asarray(sh.geom.geometry.position.location), (0, 0, 0)) + + +def test_baked_geometry_renders_placed_via_stream_and_occ(): + """The baked ``solid_geom()`` tessellates to WORLD coordinates on both the libtess2 stream + kernel and OCC — the actual bug the baker fixes (a rotated shape rendered unplaced).""" + import pytest + + place = Placement.from_axis_angle([0, 0, 1], 90, origin=(5, 2, 0)) + sh = _box_shape(place) + sg = sh.solid_geom() + + # local box (0,0,0)-(2,1,1) rotated 90 about Z at (5,2,0) -> x in [4,5], y in [2,4], z in [0,1] + def _check_bbox(mn, mx): + assert 3.9 < mn[0] and mx[0] < 5.1, (mn, mx) + assert 1.9 < mn[1] and mx[1] < 4.1, (mn, mx) + + # OCC path (solid_occ builds from the baked solid_geom) + occ = pytest.importorskip("OCC") # noqa: F841 + from OCC.Core.Bnd import Bnd_Box + from OCC.Core.BRepBndLib import brepbndlib + + bb = Bnd_Box() + brepbndlib.Add(sh.solid_occ(), bb) + xmin, ymin, zmin, xmax, ymax, zmax = bb.Get() + _check_bbox((xmin, ymin, zmin), (xmax, ymax, zmax)) + + +def test_baked_geometry_renders_placed_via_libtess2(): + import pytest + + pytest.importorskip("adacpp") + from ada.cad import AdacppBackend + + place = Placement.from_axis_angle([0, 0, 1], 90, origin=(5, 2, 0)) + sh = _box_shape(place) + sg = sh.solid_geom() + + be = AdacppBackend() + m = be.tessellate_stream([("b", sg)], pipeline="libtess2", deflection=2.0, angular_deg=20.0) + pos_attr = getattr(m, "positions", None) + if pos_attr is None: + pos_attr = m.position + pts = np.asarray(pos_attr, dtype=float).reshape(-1, 3) + mn, mx = pts.min(0), pts.max(0) + assert 3.9 < mn[0] and mx[0] < 5.1, (mn, mx) + assert 1.9 < mn[1] and mx[1] < 4.1, (mn, mx) diff --git a/tests/core/cadit/ifc/read/beams/test_ifc_read_beams.py b/tests/core/cadit/ifc/read/beams/test_ifc_read_beams.py index 4b6c4f118..ef9d5f615 100644 --- a/tests/core/cadit/ifc/read/beams/test_ifc_read_beams.py +++ b/tests/core/cadit/ifc/read/beams/test_ifc_read_beams.py @@ -1,8 +1,20 @@ +import re +import numpy as np import pytest import ada +def _world(bm: ada.Beam, pt) -> tuple: + """The beam endpoint in world coords: apply the full placement transform to the + LOCAL n1/n2. These beams carry a rotated ObjectPlacement (local Z extruded onto + world X), so the placement rotation must be applied — a naive ``origin + n1`` does + not, and would silently mask a wrong-axis placement (beam-standard-case.ifc).""" + m = bm.placement.get_matrix4x4() + q = m @ np.array([pt[0], pt[1], pt[2], 1.0]) + return tuple(np.round(q[:3], 4)) + + def test_read_standard_case_beams(example_files, tmp_path): a = ada.from_ifc(example_files / "ifc_files/beams/beam-standard-case.ifc") @@ -11,19 +23,19 @@ def test_read_standard_case_beams(example_files, tmp_path): p = a.get_by_name("Building") assert len(p.beams) == 18 + # World-space beam axes, verified against ifcopenshell's create_shape (USE_WORLD_COORDS). + # The A-row beams run along world X at increasing Y; the placement swaps local Z -> world X. bm_a1: ada.Beam = p.get_by_name("A-1") - assert tuple(bm_a1.n1.p) == (-0.055, 0.11, 0.0) - assert tuple(bm_a1.n2.p) == (1.945, 0.11, 0.0) + assert _world(bm_a1, bm_a1.n1.p) == (0.0, -0.055, 0.11) + assert _world(bm_a1, bm_a1.n2.p) == (2.0, -0.055, 0.11) bm_a2: ada.Beam = p.get_by_name("A-2") - o = bm_a2.placement - assert tuple(o.origin + bm_a2.n1.p) == (0.0, 1.61, 0.0) - assert tuple(o.origin + bm_a2.n2.p) == (2.0, 1.61, 0.0) + assert _world(bm_a2, bm_a2.n1.p) == (0.0, 1.5, 0.11) + assert _world(bm_a2, bm_a2.n2.p) == (2.0, 1.5, 0.11) bm_b1: ada.Beam = p.get_by_name("B-1") - o = bm_b1.placement - assert tuple(o.origin + bm_b1.n1.p) == (-0.075, 0.075, 1.5) - assert tuple(o.origin + bm_b1.n2.p) == pytest.approx((2.865, 0.318, 2.046), abs=1e-3) + assert _world(bm_b1, bm_b1.n1.p) == pytest.approx((-0.0149, -0.0387, 1.5976), abs=1e-3) + assert _world(bm_b1, bm_b1.n2.p) == pytest.approx((2.9249, 0.2043, 2.1436), abs=1e-3) def test_read_extruded_solid_beams(example_files): @@ -32,8 +44,10 @@ def test_read_extruded_solid_beams(example_files): assert len(p.beams) == 1 bm = p.beams[0] - assert tuple(bm.n1.p) == (0.0, 0.0, 0.0) - assert tuple(bm.n2.p) == (0.0, 10.0, 0.0) + # n1/n2 are in the beam's LOCAL frame (the extrusion is along local Z); the ObjectPlacement + # rotates that onto world +Y. Check the WORLD endpoints via the placement transform. + assert tuple(_world(bm, bm.n1.p)) == (0.0, 0.0, 0.0) + assert tuple(_world(bm, bm.n2.p)) == (0.0, 10.0, 0.0) def test_read_varying_cardinal_points(example_files): @@ -55,3 +69,63 @@ def test_read_revolved_solid(example_files): a = ada.from_ifc(example_files / "ifc_files/beams/beam-revolved-solid.ifc") _ = a.to_ifc(file_obj_only=True) print(a) + + +def test_extruded_solid_beam_winding_is_outward(example_files, monkeypatch): + """The production NGEOM libtess2 stream tessellates the extruded beam with OUTWARD-facing + normals (positive signed volume). A CW-discretized profile loop previously came out + inside-out (negative volume -> dark shading). Gated to ada-cpp (libtess2 needs it).""" + import trimesh + + from ada.cad import active_backend + + if active_backend().name != "adacpp": + pytest.skip("NGEOM libtess2 stream tessellation is the ada-cpp path") + + monkeypatch.setenv("ADA_STREAM_TESS_PIPELINE", "libtess2") + a = ada.from_ifc(example_files / "ifc_files/beams/beam-extruded-solid.ifc") + bm = next(iter(a.get_all_physical_objects())) + sc = a.to_trimesh_scene(merge_meshes=True) + m = trimesh.util.concatenate([g for g in sc.geometry.values() if hasattr(g, "faces")]) + # Right magnitude (area * length) AND positive sign (outward winding). + # Sharp-I ``Ax`` underestimates the real (filleted) IPE600 area by ~4%, so the tessellated + # solid — which now includes the web/flange fillets — sits a few % above ``Ax*length``. + # Band-check the magnitude (catches gross errors) but keep the strict positive-volume + # winding guard. + exp = bm.section.properties.Ax * 10.0 # 10 m long + assert m.volume > 0, f"extrusion is inside-out (negative volume {m.volume:.5f})" + assert exp * 0.97 < m.volume < exp * 1.10, f"volume {m.volume:.5f} out of band around {exp:.5f}" + + +def test_read_varying_cardinal_points_world_positions(example_files): + """The 4 beams in beam-varying-cardinal-points.ifc each carry a different CardinalPoint + (1/2/8/9). The authoring tool bakes that offset into the extrusion's Position, and each + beam's rotated ObjectPlacement (no PlacementRelTo) must be applied — the old no-parent + import path dropped the placement, collapsing every profile onto its axis. World bboxes + verified against ifcopenshell.geom (USE_WORLD_COORDS).""" + import numpy as np + import trimesh + + a = ada.from_ifc(example_files / "ifc_files/beams/beam-varying-cardinal-points.ifc") + # (min, max) world bbox per beam name. + expected = { + "BotLeft": ((0.5, 0.0, 0.0), (0.6, 1.0, 0.2)), + "BotMid": ((-0.05, 0.0, 0.0), (0.05, 1.0, 0.2)), + "TopMid": ((-0.05, 0.0, -0.2), (0.05, 1.0, 0.0)), + "TopRight": ((0.4, 0.0, -0.2), (0.5, 1.0, 0.0)), + } + sc = a.to_trimesh_scene(merge_meshes=False) + seen = {} + for node in sc.graph.nodes_geometry: + m = re.search(r"name=(\w+)", str(node)) + if not m: + continue + T, gn = sc.graph[node] + v = trimesh.transformations.transform_points(np.asarray(sc.geometry[gn].vertices), T) + seen[m.group(1)] = (v.min(0), v.max(0)) + for nm, (emn, emx) in expected.items(): + assert nm in seen, f"{nm} not rendered" + amn, amx = seen[nm] + assert np.allclose(amn, emn, atol=1e-3) and np.allclose(amx, emx, atol=1e-3), ( + f"{nm}: cardinal offset wrong — got {np.round(amn,3)}..{np.round(amx,3)}, want {emn}..{emx}" + ) diff --git a/tests/core/cadit/ifc/read/test_bs_samples.py b/tests/core/cadit/ifc/read/test_bs_samples.py new file mode 100644 index 000000000..f4fb969a1 --- /dev/null +++ b/tests/core/cadit/ifc/read/test_bs_samples.py @@ -0,0 +1,96 @@ +"""Regressions from the corpus audit sweep over the buildingSMART sample set +(files under files/ifc_files/bs_samples/, all public buildingSMART samples). + +Each file previously errored (or silently dropped geometry) across every +conversion target: + +* inch units (unit scale 0.0254) raised NotImplementedError at read +* IfcTriangulatedFaceSet imported but tessellated to an empty scene +* type-library files (geometry only on an IfcTypeProduct RepresentationMap, + no placed products) imported zero objects +* IfcRectangleProfileDef swept profiles had no arbitrary-profile conversion +* IfcTrimmedCurve parameter trims (degrees AND radians files) were rejected, + and the PartialEllipse column crashed the 2D-placement ellipse reader +""" + +import pytest + +import ada + + +def _read(example_files, name, monkeypatch): + monkeypatch.setenv("ADA_IFC_IMPORT_SHAPE_GEOM", "true") + ada.config.Config().reload_config() + return ada.from_ifc(example_files / f"ifc_files/bs_samples/{name}") + + +def _glb_tri_count(a, tmp_path) -> int: + import trimesh + + glb = tmp_path / "out.glb" + a.to_gltf(glb) + scene = trimesh.load(glb) + return sum(len(g.faces) for g in scene.geometry.values() if hasattr(g, "faces")) + + +def test_inch_units_triangulated_column(example_files, monkeypatch, tmp_path): + # Inch-unit file (scale 0.0254): read must convert instead of raising, and + # the IfcTriangulatedFaceSet body must reach the GLB as a real mesh + # (direct mesh path — no kernel build exists for it). + a = _read(example_files, "column-straight-rectangle-tessellation.ifc", monkeypatch) + objects = list(a.get_all_physical_objects()) + assert len(objects) == 1 + # 8"x8" column, 10ft tall — converted to meters. + geom = objects[0].geom.geometry + assert max(abs(float(c)) for p in geom.coordinates for c in p) == pytest.approx(3.048) + assert _glb_tri_count(a, tmp_path) == 12 + + +def test_type_library_texture_file(example_files, monkeypatch, tmp_path): + # Geometry lives on an IfcBoilerType RepresentationMap; no placed product + # exists. The instance-less type import must pick it up. + a = _read(example_files, "tessellation-with-image-texture.ifc", monkeypatch) + objects = list(a.get_all_physical_objects()) + assert len(objects) == 1 + assert _glb_tri_count(a, tmp_path) > 0 + + +def test_rectangle_profile_extrusion(example_files, monkeypatch, tmp_path): + a = _read(example_files, "extruded-solid.ifc", monkeypatch) + objects = list(a.get_all_physical_objects()) + assert len(objects) == 1 + assert _glb_tri_count(a, tmp_path) > 0 + # The AP242 stream writer converts the parametric profile too. + out = tmp_path / "out.stp" + a.to_stp(str(out), writer="stream", fuse_fem=False) + assert out.stat().st_size > 0 + + +@pytest.mark.parametrize("unit", ["degrees", "radians"]) +def test_trimmed_curve_parameters(example_files, monkeypatch, tmp_path, unit): + # Parameter trims: line basis (IfcVector magnitude scales t), circle basis + # (angle in the file's plane-angle unit), ellipse basis (2D placement + + # sampled arc). All three columns must import and tessellate. + a = _read(example_files, f"curve-parameters-in-{unit}.ifc", monkeypatch) + objects = list(a.get_all_physical_objects()) + assert len(objects) == 3 + assert _glb_tri_count(a, tmp_path) > 0 + + +def test_trimmed_curve_degrees_radians_parity(example_files, monkeypatch, tmp_path): + # The two files describe the same geometry in different plane-angle units — + # after read-time normalization they must produce identical meshes. + import numpy as np + import trimesh + + bounds = [] + tris = [] + for unit in ("degrees", "radians"): + a = _read(example_files, f"curve-parameters-in-{unit}.ifc", monkeypatch) + glb = tmp_path / f"{unit}.glb" + a.to_gltf(glb) + scene = trimesh.load(glb) + bounds.append(scene.bounds) + tris.append(sum(len(g.faces) for g in scene.geometry.values() if hasattr(g, "faces"))) + assert tris[0] == tris[1] + assert np.allclose(bounds[0], bounds[1], atol=1e-6) diff --git a/tests/core/cadit/ifc/read/test_read_alignment.py b/tests/core/cadit/ifc/read/test_read_alignment.py new file mode 100644 index 000000000..5c177252e --- /dev/null +++ b/tests/core/cadit/ifc/read/test_read_alignment.py @@ -0,0 +1,131 @@ +"""Native import + evaluation of IFC4x3 alignment reference curves (no OCC). + +Regression for the "zero geometry" bug on alignment files: an IfcAlignment (and its segments) +carry only curve representations ('Axis' Curve3D / 'FootPrint' Curve2D / 'Segment'), so the +generic shape importer skipped them and the file rendered empty. The alignment reader now +evaluates the analytic curve — IfcSegmentedReferenceCurve (cant) over an IfcGradientCurve whose +horizontal base is an IfcCompositeCurve of IfcCosineSpiral / IfcLine segments — to a sampled 3D +polyline that renders as GL_LINES. + +Ground-truth values are the ifcopenshell.geom (USE_WORLD_COORDS) oracle for +``segmented-reference-curve.ifc`` (buildingSMART IFC4x3 sample); the analytic evaluator matches +them to ~1e-6 (cant z to machine precision). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ada +from ada.config import Config + +FIXTURE = "ifc_files/segmented-reference-curve.ifc" + +# Oracle endpoints (ifcopenshell.geom, USE_WORLD_COORDS) for this fixture. +_HORIZONTAL_END = (98.9299, 13.1346) # cosine-spiral horizontal, s = 100 m +_REF_CURVE_END = (98.9299, 13.1346, -7.92) # 3D reference curve with cant, s = 100 m +_CANT_Z = {0.0: 0.08, 50.0: -3.92, 100.0: -7.92} # cant z at base stations + + +@pytest.fixture(autouse=True) +def _enable_geom(): + Config().update_config_globally("ifc_import_shape_geom", True) + + +def test_alignment_file_imports_curve_geometry(example_files): + """The file used to import as 0 physical objects (every product skipped). It now yields the + IfcAlignment reference curve plus its segments, each a PolyLine.""" + from ada.geom.curves import PolyLine + + a = ada.from_ifc(example_files / FIXTURE) + objs = list(a.get_all_physical_objects()) + assert len(objs) >= 4, f"expected the alignment + segments, got {len(objs)}" + polylines = [o for o in objs if o.geom is not None and isinstance(o.geom.geometry, PolyLine)] + assert len(polylines) >= 4 + assert all(len(o.geom.geometry.points) >= 2 for o in polylines) + + +def test_cosine_spiral_horizontal_matches_oracle(example_files): + """IfcCosineSpiral evaluation: the horizontal footprint endpoint and the heading angle at the + end (which equals the next segment's start bearing, 0.216667 rad).""" + import ifcopenshell + + from ada.cadit.ifc.read.geom.curves import get_curve + from ada.cadit.ngeom._alignment_sweep import _cosine_spiral_theta, composite_curve_points + + f = ifcopenshell.open(str(example_files / FIXTURE)) + # #65 is the FootPrint IfcCompositeCurve (cosine-spiral horizontal). + cc = get_curve(f.by_id(65)) + pts = composite_curve_points(cc, 200) + assert np.allclose(pts[-1][:2], _HORIZONTAL_END, atol=1e-3) + assert np.isclose(pts[0][0], 0.0, atol=1e-6) and np.isclose(pts[0][1], 0.0, atol=1e-6) + + spiral = cc.segments[0].parent_curve # the IfcCosineSpiral + theta_end = float(_cosine_spiral_theta(spiral, 100.0, np.array([100.0]))[0]) + assert np.isclose(theta_end, 0.2166667, atol=1e-6) + + +def test_segmented_reference_curve_cant_z(example_files): + """IfcSegmentedReferenceCurve: (x,y) equals the base gradient curve, and the cant produces the + exact cosine vertical offset z(s) = e0 + (L^2/CosineTerm)(cos(pi*s/L) - 1).""" + import ifcopenshell + + from ada.cadit.ifc.read.geom.curves import get_curve + from ada.cadit.ngeom._alignment_sweep import segmented_reference_curve_points + + f = ifcopenshell.open(str(example_files / FIXTURE)) + src = get_curve(f.by_id(112)) # IfcSegmentedReferenceCurve + pts = segmented_reference_curve_points(src, 200) # 201 pts at uniform base station 0..100 + + assert np.allclose(pts[0], (0.0, 0.0, 0.08), atol=1e-4) + assert np.allclose(pts[-1], _REF_CURVE_END, atol=1e-3) + # cant z at the sampled stations (index = station since n_per == 200 over 100 m) + for station, z in _CANT_Z.items(): + assert np.isclose(pts[int(station * 2), 2], z, atol=1e-4), f"cant z at s={station}" + + +def test_sectioned_solid_horizontal_native(example_files): + """IfcSectionedSolidHorizontal (a profile swept along the alignment directrix over a distance + range) reads natively as a triangulated swept shell — NOT the OCC-kernel explosion into + thousands of loose faces — so it renders and STEP-round-trips as one solid. bbox matches the + ifcopenshell oracle.""" + import ada.geom.surfaces as su + + a = ada.from_ifc(example_files / "ifc_files/sectioned-solid-horizontal.ifc") + objs = list(a.get_all_physical_objects()) + assert len(objs) == 8 # 1 sectioned solid + 7 alignment curves + + solids = [o for o in objs if o.geom is not None and isinstance(o.geom.geometry, su.TriangulatedFaceSet)] + assert len(solids) == 1, "the sectioned solid must read as one native triangulated shell" + tfs = solids[0].geom.geometry + coords = np.array([[p[0], p[1], p[2]] for p in tfs.coordinates]) + assert len(tfs.indices) % 3 == 0 and len(tfs.indices) // 3 > 100 + # oracle (ifcopenshell.geom, USE_WORLD_COORDS): swept over directrix distance 300..600 + assert np.allclose(coords.min(0), (300.0, -22.26, 148.52), atol=0.1) + assert np.allclose(coords.max(0), (599.88, 5.0, 149.7), atol=0.1) + + +def test_alignment_renders_as_lines(example_files): + """The imported polylines tessellate to GL_LINES (kernel-free discretize_curve path) with the + expected scene bbox (reference curve dips to z = -7.92 from the cant).""" + a = ada.from_ifc(example_files / FIXTURE) + objs = list(a.get_all_physical_objects()) + + from ada.occ.tessellating import BatchTessellator + from ada.visit.gltf.meshes import MeshType + + bt = BatchTessellator() + seg_total = 0 + mn = np.full(3, 1e18) + mx = np.full(3, -1e18) + for ms in bt.batch_tessellate(objs): + assert ms.type == MeshType.LINES + p = np.asarray(ms.position, dtype=float).reshape(-1, 3) + if len(p): + mn = np.minimum(mn, p.min(0)) + mx = np.maximum(mx, p.max(0)) + seg_total += len(ms.indices) // 2 + assert seg_total > 100 + assert np.isclose(mn[2], -7.92, atol=1e-2) # cant dip present + assert mx[0] > 99 and mx[1] > 12 # spans the horizontal footprint diff --git a/tests/core/cadit/ifc/read/test_read_face_based_surface_model.py b/tests/core/cadit/ifc/read/test_read_face_based_surface_model.py new file mode 100644 index 000000000..2d12b81f2 --- /dev/null +++ b/tests/core/cadit/ifc/read/test_read_face_based_surface_model.py @@ -0,0 +1,37 @@ +"""Native IfcFaceBasedSurfaceModel reader (a set of IfcConnectedFaceSet, each a set of IfcFace). + +Previously fell back to the OCC kernel. The reader now builds the native face-set hierarchy +(FaceBasedSurfaceModel -> ConnectedFaceSet -> Face -> FaceBound -> PolyLoop), which the shared +OCC/NGEOM face-set builders already tessellate. The product's ObjectPlacement is applied via the +face-set placement baker (transforms the loop vertices), so it lands in world coordinates. +""" + +from __future__ import annotations + +import numpy as np + +import ada +from ada.config import Config + + +def test_face_based_surface_model_native_and_placed(example_files): + """surface-model.ifc: an IfcFaceBasedSurfaceModel reads natively (no OCC kernel) and, with its + +1 m x ObjectPlacement applied, matches the ifcopenshell oracle bbox ([0.5,-0.5,0]..[1.5,0.5,2]).""" + import ada.geom.surfaces as su + + Config().update_config_globally("ifc_import_shape_geom", True) + a = ada.from_ifc(example_files / "ifc_files/surface-model.ifc") + objs = list(a.get_all_physical_objects()) + assert len(objs) == 1 + o = objs[0] + assert isinstance(o.geom.geometry, su.FaceBasedSurfaceModel) + assert o._occ_cache is None # not built via the IfcOpenShell kernel + + from ada.occ.tessellating import BatchTessellator + + bt = BatchTessellator() + pts = [np.asarray(ms.position, float).reshape(-1, 3) for ms in bt.batch_tessellate(objs)] + p = np.vstack([x for x in pts if len(x)]) + assert len(p) > 0 + assert np.allclose(p.min(0), (0.5, -0.5, 0.0), atol=0.02), p.min(0) + assert np.allclose(p.max(0), (1.5, 0.5, 2.0), atol=0.02), p.max(0) diff --git a/tests/core/cadit/ifc/read/test_read_gzipped_ifc.py b/tests/core/cadit/ifc/read/test_read_gzipped_ifc.py new file mode 100644 index 000000000..6acf6d3c7 --- /dev/null +++ b/tests/core/cadit/ifc/read/test_read_gzipped_ifc.py @@ -0,0 +1,39 @@ +"""Transparently open a gzip-compressed IFC shipped under a plain .ifc name. + +Some exporters/servers gzip the model but keep the .ifc extension; ifcopenshell.open then fails +with 'Unable to parse IFC SPF header'. The reader detects the gzip magic (0x1f 0x8b) and inflates +the SPF text before parsing, so from_ifc reads it like any other model. +""" + +from __future__ import annotations + +import gzip + +import ifcopenshell + +import ada +from ada.config import Config + + +def test_from_ifc_opens_gzip_compressed_ifc(example_files): + src = example_files / "ifc_files/beam-standard-case-gz.ifc" + # sanity: the fixture really is gzip and ifcopenshell.open can't read it directly + assert src.read_bytes()[:2] == b"\x1f\x8b" + + Config().update_config_globally("ifc_import_shape_geom", True) + a = ada.from_ifc(src) + objs = list(a.get_all_physical_objects()) + assert len(objs) == 18 # 18 IfcBeam in the buildingSMART beam-standard-case sample + + +def test_open_ifc_file_helper_handles_gzip_and_plain(example_files, tmp_path): + from ada.cadit.ifc.store import open_ifc_file + + gz = example_files / "ifc_files/beam-standard-case-gz.ifc" + f = open_ifc_file(gz) + assert f.schema == "IFC4" and len(f.by_type("IfcBeam")) == 18 + + # a plain (already-inflated) copy still opens the normal way + plain = tmp_path / "plain.ifc" + plain.write_text(gzip.decompress(gz.read_bytes()).decode("utf-8", "replace")) + assert isinstance(open_ifc_file(plain), ifcopenshell.file) diff --git a/tests/core/cadit/ifc/read/test_read_indexed_profiles.py b/tests/core/cadit/ifc/read/test_read_indexed_profiles.py new file mode 100644 index 000000000..2eba6bd72 --- /dev/null +++ b/tests/core/cadit/ifc/read/test_read_indexed_profiles.py @@ -0,0 +1,63 @@ +"""Native reading of two profile/curve cases that previously rendered wrong geometry. + +1. IfcRoundedRectangleProfileDef — a subtype of IfcRectangleProfileDef, so the plain-rectangle + branch used to swallow it and drop RoundingRadius, giving SHARP corners (bath-csg void). +2. IfcIndexedPolyCurve with a multi-point IfcLineIndex — a polyline through N points is N-1 edges, + but only the first two points were kept, collapsing e.g. an I-section's flange outline. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ada +from ada.config import Config + + +@pytest.fixture(autouse=True) +def _enable_geom(): + Config().update_config_globally("ifc_import_shape_geom", True) + + +def _render_bbox(a): + from ada.occ.tessellating import BatchTessellator + + objs = list(a.get_all_physical_objects()) + p = np.vstack([np.asarray(m.position, float).reshape(-1, 3) for o in objs for m in BatchTessellator().batch_tessellate([o])]) + return p.min(0), p.max(0) + + +def test_rounded_rectangle_profile_rounds_corners(example_files): + """bath-csg-solid: the IfcRoundedRectangleProfileDef void reads as an ArbitraryProfileDef with 4 + corner arcs (not a sharp rectangle), so the boolean cavity has rounded interior corners.""" + import ada.geom.curves as cu + import ada.geom.surfaces as su + + a = ada.from_ifc(example_files / "ifc_files/bath-csg-solid.ifc") + o = list(a.get_all_physical_objects())[0] + bops = o.geom.bool_operations or [] + assert bops, "expected the DIFFERENCE boolean (block - void)" + void_profile = bops[0].second_operand.geometry.swept_area + assert isinstance(void_profile, su.ArbitraryProfileDef) # not a sharp RectangleProfileDef + arcs = sum(1 for s in void_profile.outer_curve.segments if isinstance(s, cu.ArcLine)) + assert arcs == 4 # one fillet per corner + + +def test_indexed_polycurve_multipoint_line_index(example_files): + """beam-extruded-solid: the I-section profile (IfcIndexedPolyCurve with multi-point IfcLineIndex + runs) expands to consecutive edges instead of collapsing to a single edge per run — so the full + IPE200 (100mm wide x 200mm tall) is built, not a ~half-width box.""" + src = example_files / "ifc_files/beam-extruded-solid.ifc" + a = ada.from_ifc(src) + o = list(a.get_all_physical_objects())[0] + # Deterministic, backend-free: the 4 lines + 4 arcs, with the two 6-point line runs each + # expanded to 5 edges, give 16 segments (was 8 when runs collapsed to one edge). + assert len(o.geom.geometry.swept_area.outer_curve.segments) == 16 + # End-to-end: the rendered profile spans the full width/height (the collapse gave x-width ~0.047 + # instead of ~0.1). Coarse thresholds so adacpp tessellation jitter doesn't flake the test. + rmn, rmx = _render_bbox(a) + size = rmx - rmn + assert size[0] > 0.09, size # full 100mm flange width, not the collapsed ~47mm + assert size[2] > 0.18, size # full 200mm section height + assert abs(size[1] - 1.0) < 0.02, size # 1 m extrusion length diff --git a/tests/core/cadit/ifc/read/test_read_mapped_item.py b/tests/core/cadit/ifc/read/test_read_mapped_item.py new file mode 100644 index 000000000..e1c923926 --- /dev/null +++ b/tests/core/cadit/ifc/read/test_read_mapped_item.py @@ -0,0 +1,144 @@ +"""Native IfcMappedItem reader (unwrap a mapped representation + apply its transform, no OCC kernel). + +IfcMappedItem is an instancing wrapper (a MappingSource representation reused via a MappingTarget +transform). It previously fell back to the IfcOpenShell OCC kernel (the single most common corpus +fallback: 75 products / 10 files). The reader now unwraps the single mapped item, reads the +underlying geometry natively, and bakes the mapped-item 4x4 — so the product reads with no kernel +and renders. A *multi-item* mapped representation whose items are all face sets (e.g. a detailed +part exported as several IfcPolygonalFaceSets — the IfcSignal bodies in linear-placement-of-signal) +merges its items into one geometry and carries the shared transform as a mesh-level instance. +Non-rigid transforms on analytic solids, non-faceted multi-item sources, and multi-item product +bodies of differing sources degrade to the kernel fallback (verified graceful). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ada +from ada.config import Config + + +@pytest.fixture(autouse=True) +def _enable_geom(): + Config().update_config_globally("ifc_import_shape_geom", True) + + +def _tris(objs): + from ada.occ.tessellating import BatchTessellator + + bt = BatchTessellator() + return sum(len(ms.indices) // 3 for o in objs for ms in bt.batch_tessellate([o])) + + +def test_mapped_swept_disk_solid_reads_native(example_files): + """reinforcing-stirrup: an IfcMappedItem wrapping an IfcSweptDiskSolid reads natively (ada.geom + SweptDiskSolid, no OCC _occ_cache) and renders.""" + import ada.geom.solids as so + + a = ada.from_ifc(example_files / "ifc_files/reinforcing-stirrup.ifc") + objs = list(a.get_all_physical_objects()) + assert len(objs) == 1 + o = objs[0] + assert isinstance(o.geom.geometry, so.SweptDiskSolid) # unwrapped native geometry + assert o._occ_cache is None # NOT built via the IfcOpenShell kernel + assert _tris(objs) > 100 # renders (SweptDiskSolid is in solid_geom's accepted types) + + +def test_mapped_csg_solid_reads_native(example_files): + """bath-csg-solid: an IfcMappedItem wrapping an IfcCsgSolid reads natively and renders.""" + a = ada.from_ifc(example_files / "ifc_files/bath-csg-solid.ifc") + objs = list(a.get_all_physical_objects()) + assert len(objs) == 1 + assert objs[0]._occ_cache is None + assert _tris(objs) > 0 + + +def test_reinforcing_assembly_matches_oracle(example_files): + """End-to-end: reinforcing-assembly (34 mapped IfcSweptDiskSolid rebar + a concrete IfcBeam that + falls through to the shape importer). The rebar read native via IfcMappedItem, and the beam's + ABSOLUTE ObjectPlacement (a non-identity Y<->Z rotation, PlacementRelTo=None) is applied — so + the beam runs along world Y, not Z. The whole assembly bbox must match the ifcopenshell oracle + ([-0.1, 0, -0.4]..[0.1, 5, 0]).""" + a = ada.from_ifc(example_files / "ifc_files/reinforcing-assembly.ifc") + objs = list(a.get_all_physical_objects()) + + from ada.occ.tessellating import BatchTessellator + + bt = BatchTessellator() + pts = [np.asarray(ms.position, float).reshape(-1, 3) for o in objs for ms in bt.batch_tessellate([o])] + p = np.vstack([x for x in pts if len(x)]) + assert np.allclose(p.min(0), (-0.1, 0.0, -0.4), atol=0.02), p.min(0) + assert np.allclose(p.max(0), (0.1, 5.0, 0.0), atol=0.02), p.max(0) + + +def test_transform_geometry_rigid_and_nonrigid(): + """The mapped-item geometry transform: identity is a no-op, a rigid transform moves a face set's + coordinates, and a non-uniform/shear transform raises (so the caller keeps the kernel path).""" + from ada.cadit.ifc.read.geom.geom_reader import _transform_geometry + from ada.geom import surfaces as su + from ada.geom.points import Point + + fs = su.PolygonalFaceSet(coordinates=[Point(0, 0, 0), Point(1, 0, 0), Point(0, 1, 0)], faces=[[1, 2, 3]]) + + assert _transform_geometry(fs, np.eye(4)) is fs # identity: unchanged object + + trans = np.eye(4) + trans[:3, 3] = (10.0, 20.0, 30.0) # rigid translation + moved = _transform_geometry(fs, trans) + assert np.allclose(np.asarray(moved.coordinates[0]), (10, 20, 30)) + + shear = np.eye(4) + shear[0, 1] = 0.5 # non-rigid shear + with pytest.raises(NotImplementedError): + _transform_geometry(fs, shear) + + +def test_merge_face_sets_polygonal_offsets_indices(): + """A multi-item mapped representation's face sets merge into one: coordinates concatenate and the + 1-based face indices of later items shift by the running vertex count (the core of the + linear-placement-of-signal fix — 2 IfcSignals, each a 5-IfcPolygonalFaceSet mapped body).""" + from ada.cadit.ifc.read.geom.geom_reader import _merge_face_sets + from ada.geom import surfaces as su + from ada.geom.points import Point + + a = su.PolygonalFaceSet(coordinates=[Point(0, 0, 0), Point(1, 0, 0), Point(0, 1, 0)], faces=[[1, 2, 3]]) + b = su.PolygonalFaceSet(coordinates=[Point(2, 0, 0), Point(3, 0, 0), Point(2, 1, 0)], faces=[[1, 2, 3]]) + + merged = _merge_face_sets([a, b]) + assert isinstance(merged, su.PolygonalFaceSet) + assert len(merged.coordinates) == 6 + # first item's indices unchanged; second item's shifted by 3 (a's vertex count) + assert merged.faces == [[1, 2, 3], [4, 5, 6]] + assert np.allclose(np.asarray(merged.coordinates[3]), (2, 0, 0)) + + +def test_merge_face_sets_triangulated_offsets_indices(): + """Triangulated face sets merge the same way (flat 1-based CoordIndex, offset per item).""" + from ada.cadit.ifc.read.geom.geom_reader import _merge_face_sets + from ada.geom import surfaces as su + from ada.geom.direction import Direction + from ada.geom.points import Point + + n = Direction(0, 0, 1) + a = su.TriangulatedFaceSet(coordinates=[Point(0, 0, 0), Point(1, 0, 0), Point(0, 1, 0)], normals=[n], indices=[1, 2, 3]) + b = su.TriangulatedFaceSet(coordinates=[Point(2, 0, 0), Point(3, 0, 0), Point(2, 1, 0)], normals=[n], indices=[1, 2, 3]) + + merged = _merge_face_sets([a, b]) + assert isinstance(merged, su.TriangulatedFaceSet) + assert len(merged.coordinates) == 6 + assert merged.indices == [1, 2, 3, 4, 5, 6] + + +def test_merge_face_sets_rejects_non_face_sets(): + """Mixed/non-face-set items can't merge — raises so the mapped-item caller keeps the kernel path.""" + from ada.cadit.ifc.read.geom.geom_reader import _merge_face_sets + from ada.geom import solids as so + from ada.geom import surfaces as su + from ada.geom.points import Point + + fs = su.PolygonalFaceSet(coordinates=[Point(0, 0, 0), Point(1, 0, 0), Point(0, 1, 0)], faces=[[1, 2, 3]]) + box = so.Box.from_2points(Point(0, 0, 0), Point(1, 1, 1)) + with pytest.raises(NotImplementedError): + _merge_face_sets([fs, box]) diff --git a/tests/core/cadit/ifc/read/test_read_shape_with_transformation.py b/tests/core/cadit/ifc/read/test_read_shape_with_transformation.py index 17a0eef07..845301a71 100644 --- a/tests/core/cadit/ifc/read/test_read_shape_with_transformation.py +++ b/tests/core/cadit/ifc/read/test_read_shape_with_transformation.py @@ -1,3 +1,5 @@ +import numpy as np + import ada from ada.config import Config from ada.geom.solids import Box @@ -9,14 +11,48 @@ def test_read_shape_w_transformation(example_files): print(a) +def test_placement_from_ifc_4x4_roundtrips(): + """The IFC placement helper must produce a Placement whose get_matrix4x4 — which the + scene applies as M@p — reproduces the input world matrix. from_4x4_matrix alone returns + the transpose (columns vs rows), which renders axis-swapping placements wrong.""" + from ada.cadit.ifc.read.geom.placement import placement_from_ifc_4x4 + + # world transform: 90deg about Z + translation (a genuine axis swap, not symmetric) + m = np.array([[0.0, -1.0, 0.0, -0.42], [1.0, 0.0, 0.0, -0.29], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) + p = placement_from_ifc_4x4(m) + assert np.allclose(p.get_matrix4x4(), m) + # local X (1,0,0) maps to the rotated world direction, not back to (1,0,0) + assert np.allclose((p.get_matrix4x4() @ np.array([1.0, 0, 0, 1]))[:3], (-0.42, 0.71, 0.0)) + + def test_read_rotated_box(example_files): Config().update_config_globally("ifc_import_shape_geom", True) a = ada.from_ifc(example_files / "ifc_files/box_rotated.ifc") door1 = a.get_by_name("door1") door2 = a.get_by_name("door2") - geom1 = door1.geom.geometry - assert isinstance(geom1, Box) + assert isinstance(door1.geom.geometry, Box) + assert isinstance(door2.geom.geometry, Box) + + # door2 is door1 rotated 90deg about Z: its placement must carry that rotation (local X + # -> world +Y), where the transpose bug previously left it wrong. + r2 = np.array(door2.placement.get_matrix4x4())[:3, :3] + assert np.allclose(r2 @ np.array([1.0, 0, 0]), (0.0, 1.0, 0.0), atol=1e-6) + r1 = np.array(door1.placement.get_matrix4x4())[:3, :3] + assert np.allclose(r1, np.eye(3), atol=1e-6) + + +def test_rotated_shape_placement_roundtrips(tmp_path): + """A shape under a rotated part placement survives an IFC write->read with the placement + applied correctly: box corner (3,1,2) local -> world under a 90deg-about-Z frame at (0,5,0).""" + from ada import Placement + + box = ada.PrimBox("b", (0, 0, 0), (3, 1, 2)) + part = ada.Part("P", placement=Placement(origin=(0, 5, 0), xdir=(0, 1, 0), ydir=(-1, 0, 0), zdir=(0, 0, 1))) + (ada.Assembly() / (part / box)).to_ifc(tmp_path / "rotshape.ifc") - geom2 = door2.geom.geometry - assert isinstance(geom2, Box) + b = ada.from_ifc(tmp_path / "rotshape.ifc") + o = next(iter(b.get_all_physical_objects())) + m = o.placement.get_matrix4x4() + assert np.allclose((m @ np.array([0.0, 0, 0, 1]))[:3], (0.0, 5.0, 0.0), atol=1e-6) + assert np.allclose((m @ np.array([3.0, 1, 2, 1]))[:3], (-1.0, 8.0, 2.0), atol=1e-6) diff --git a/tests/core/cadit/ifc/roundtripping/test_roundtrip_revolved_beam.py b/tests/core/cadit/ifc/roundtripping/test_roundtrip_revolved_beam.py index ca73497fe..84c3aef38 100644 --- a/tests/core/cadit/ifc/roundtripping/test_roundtrip_revolved_beam.py +++ b/tests/core/cadit/ifc/roundtripping/test_roundtrip_revolved_beam.py @@ -84,3 +84,134 @@ def test_import_buildingsmart_revolved_beam(example_files): vol = _solid_volume(a) expected = bm.section.properties.Ax * (bm.curve.radius * np.deg2rad(bm.curve.angle)) assert abs(vol - expected) / expected < 0.05 + + +def test_buildingsmart_revolved_beam_arc_is_smooth(example_files): + """The revolved beam is a large-radius (7.25 m) arc: its tessellation must sample + the sweep finely enough that the bulge apex reaches the true arc extent, not fall + short as a coarse few-segment polygon does. Guards the tessellator's angular + deflection: a loose one facets the arc and clips the apex (~2% short). + + ada-cpp (the worker/viewer backend) meshes with an explicit 0.2 rad angular + deflection. The pythonocc default path (ShapeTesselator) exposes no angular + control — only a relative mesh_quality — so it stays coarse here; skip it (its + finer BRepMesh path is env-gated, a separate follow-up).""" + import numpy as np + import pytest + + from ada.cad import active_backend + + if active_backend().name != "adacpp": + pytest.skip("angular-deflection smoothness is the ada-cpp tessellation path (worker/viewer)") + + a = ada.from_ifc(example_files / "ifc_files/beams/beam-revolved-solid.ifc") + scene = a.to_trimesh_scene(merge_meshes=True) + verts = np.vstack([np.asarray(g.vertices) for g in scene.geometry.values() if hasattr(g, "vertices")]) + + bm = next(o for o in a.get_all_physical_objects() if isinstance(o, ada.BeamRevolve)) + # Neutral-axis sagitta of the arc = r(1 - cos(theta/2)); the outer surface apex sits + # a further radial half-width out. The tessellated max-x must reach nearly there. + theta = np.deg2rad(bm.curve.angle) + sagitta = bm.curve.radius * (1.0 - np.cos(theta / 2.0)) + apex = sagitta + bm.section.w_top / 2.0 # ~2.11 m for this IPE600 beam + assert verts[:, 0].max() > apex * 0.99, f"arc apex clipped: {verts[:, 0].max():.3f} vs ~{apex:.3f}" + + +def test_buildingsmart_revolved_beam_via_ngeom_stream(example_files, monkeypatch): + """Production (worker/viewer) GLB uses the NGEOM libtess2 stream tessellator, NOT the + OCC BatchTessellator the other tests exercise. That path had the revolve wrong three + ways: the angle was fed in as radians not degrees (~14 turns -> profile splattered + ±12 m around the axis); the revolve axis was left in global coords while the tessellator + expects it position-local (axis ended up parallel to the profile normal -> the sweep + collapsed flat); and partial revolves emitted no end caps with inside-out winding + (negative volume -> dark shading). Assert the streamed solid sits in the right place, + closed and outward-facing. Gated to ada-cpp (libtess2 needs it).""" + import numpy as np + import pytest + import trimesh + + from ada.cad import active_backend + + if active_backend().name != "adacpp": + pytest.skip("NGEOM libtess2 stream tessellation is the ada-cpp path") + + monkeypatch.setenv("ADA_STREAM_TESS_PIPELINE", "libtess2") + a = ada.from_ifc(example_files / "ifc_files/beams/beam-revolved-solid.ifc") + bm = next(o for o in a.get_all_physical_objects() if isinstance(o, ada.BeamRevolve)) + sc = a.to_trimesh_scene(merge_meshes=True) + m = trimesh.util.concatenate([g for g in sc.geometry.values() if hasattr(g, "faces")]) + lo, hi = np.asarray(m.vertices).min(0), np.asarray(m.vertices).max(0) + + # Arc chord runs along +Y to ~10 m; profile depth keeps Z within ±0.3; bulge in +X ~2 m. + # Splatter (angle-as-radians) blows Z/X out to ±12; flat (global axis) puts the arc on + # the wrong plane so Y never reaches 10. + assert hi[1] == pytest.approx(10.08, abs=0.3), f"arc doesn't sweep to p2 along Y: ymax={hi[1]:.2f}" + assert abs(lo[2]) < 0.4 and abs(hi[2]) < 0.4, f"profile splattered out of plane in Z: {lo[2]:.2f}..{hi[2]:.2f}" + assert 1.5 < hi[0] < 2.3, f"bulge apex X off: {hi[0]:.2f}" + # Caps present + outward winding -> a closed, positive-volume solid near the analytic value. + exp = bm.section.properties.Ax * (bm.curve.radius * np.deg2rad(bm.curve.angle)) + # Filleted IPE600 sits a few % above the sharp-Ax analytic (area*arc-length); band-check + # the magnitude, keep the positive-volume winding guard. + assert m.volume > 0, f"inside-out winding (negative volume {m.volume:.3f})" + assert exp * 0.95 < m.volume < exp * 1.12, f"volume {m.volume:.4f} vs analytic {exp:.4f}" + + +def _revolve_curve_params(model): + bm = next(o for o in model.get_all_physical_objects() if isinstance(o, ada.BeamRevolve)) + c = bm.curve + return bm, { + "radius": round(float(c.radius), 3), + "angle": round(float(c.angle), 2), + "rot_axis": [round(float(x), 3) for x in c.rot_axis], + "section": bm.section.name, + } + + +def _stream_bbox_vol(model): + import numpy as np + + sc = model.to_trimesh_scene(merge_meshes=True) + m = trimesh.util.concatenate([g for g in sc.geometry.values() if hasattr(g, "faces")]) + v = np.asarray(m.vertices) + return v.min(0), v.max(0), float(m.volume) + + +def test_buildingsmart_revolved_beam_ifc_roundtrip_via_stream(example_files, tmp_path, monkeypatch): + """The revolved beam must survive an IFC write->read round-trip: the CurveRevolve + parameters (radius, sweep angle, revolution axis, section) preserved AND the + production NGEOM-tessellated solid landing in the same place, closed and outward. + Exercises the libtess2 stream path (the worker/viewer + the path that carried the + original splatter/flat/inside-out bug). Gated to ada-cpp.""" + import numpy as np + import pytest + + from ada.cad import active_backend + + if active_backend().name != "adacpp": + pytest.skip("NGEOM libtess2 stream tessellation is the ada-cpp path") + + monkeypatch.setenv("ADA_STREAM_TESS_PIPELINE", "libtess2") + + a0 = ada.from_ifc(example_files / "ifc_files/beams/beam-revolved-solid.ifc") + _, p0 = _revolve_curve_params(a0) + lo0, hi0, vol0 = _stream_bbox_vol(a0) + + a0.to_ifc(tmp_path / "rt.ifc") + a1 = ada.from_ifc(tmp_path / "rt.ifc") + bm1, p1 = _revolve_curve_params(a1) + lo1, hi1, vol1 = _stream_bbox_vol(a1) + + # Parameters preserved exactly through the round-trip. + assert p1 == p0 == {"radius": 7.25, "angle": 87.21, "rot_axis": [0.0, 0.0, 1.0], "section": "IPE600"} + + # Geometry preserved: still the correct arc (Y->10, Z in-plane, X-bulge), not splattered + # or flat, and still a positive-volume closed solid near the analytic value. + assert hi1[1] == pytest.approx(10.08, abs=0.3) + assert abs(lo1[2]) < 0.4 and abs(hi1[2]) < 0.4 + assert 1.9 < hi1[0] < 2.2 # bulge apex ~2.08 at the 10deg corpus default + # Filleted IPE600 sits a few % above the sharp-Ax analytic (area*arc-length); band-check. + exp = bm1.section.properties.Ax * (bm1.curve.radius * np.deg2rad(bm1.curve.angle)) + assert vol1 > 0 and exp * 0.95 < vol1 < exp * 1.12 + # Round-trip is stable, not just valid: same bbox + volume as before the write. + assert np.allclose(lo1, lo0, atol=1e-3) and np.allclose(hi1, hi0, atol=1e-3) + assert vol1 == pytest.approx(vol0, rel=1e-3) diff --git a/tests/core/cadit/ngeom/test_adaptive_tess_density.py b/tests/core/cadit/ngeom/test_adaptive_tess_density.py new file mode 100644 index 000000000..40c8e9e9f --- /dev/null +++ b/tests/core/cadit/ngeom/test_adaptive_tess_density.py @@ -0,0 +1,77 @@ +"""Adaptive per-surface angular tessellation density (model-relative coarsening). + +A fixed angular ceiling facets every curved surface the same regardless of size, so dense +assemblies of tiny features over-tessellate. Adaptive mode (opt-in) relaxes the ceiling for +surfaces small RELATIVE TO THE MODEL — keyed on ``model_scale`` (the model bbox diagonal) — so a +standalone small part stays fine while the same-radius feature inside a large model coarsens. + +The coarsening itself lives in adacpp (angle_step); these tests pin the adapy contract: the +registry toggle / estimator, and that model_scale actually flows to the kernel and changes density. +""" + +from __future__ import annotations + +import numpy as np +import pytest + + +def _adacpp_supports_model_scale() -> bool: + try: + import adacpp + + return "model_scale" in (getattr(adacpp.cad.tessellate_stream, "__doc__", "") or "") + except Exception: + return False + + +def test_registry_toggle_and_model_scale(monkeypatch): + from ada.cad.registry import stream_tess_adaptive, stream_tess_model_scale + + monkeypatch.delenv("ADA_STREAM_TESS_ADAPTIVE", raising=False) + assert stream_tess_adaptive() is False # default OFF (explicit-global-angle mode) + assert stream_tess_model_scale() == 0.0 + + monkeypatch.setenv("ADA_STREAM_TESS_ADAPTIVE", "1") + monkeypatch.setenv("ADA_STREAM_TESS_MODEL_SCALE", "26000") + assert stream_tess_adaptive() is True + assert stream_tess_model_scale() == pytest.approx(26000.0) + + # adaptive off => model_scale ignored even if set + monkeypatch.setenv("ADA_STREAM_TESS_ADAPTIVE", "off") + assert stream_tess_model_scale() == 0.0 + + +def test_estimate_step_model_scale_robust(tmp_path): + """The estimator returns a positive scale and rejects a far-flung outlier point.""" + from ada.cadit.step.model_scale import estimate_step_model_scale + + import ada + + src = tmp_path / "box.step" + (ada.Assembly("a") / (ada.Part("p") / ada.PrimBox("b", (0, 0, 0), (2, 1, 1)))).to_stp(src) + ms = estimate_step_model_scale(src) + assert ms >= 0.0 # small clean model: a finite scale (or 0 if too few points) — never negative + + +@pytest.mark.skipif(not _adacpp_supports_model_scale(), reason="adacpp build predates model_scale param") +def test_model_scale_coarsens_small_feature_but_not_standalone(): + """model_scale flows to the kernel: a cylinder that is a large fraction of a SMALL model keeps + its facets, but the SAME cylinder treated as a tiny feature of a HUGE model coarsens.""" + import ada + from ada.cad import AdacppBackend + + be = AdacppBackend() + g = ada.PrimCyl("c", (0, 0, 0), (0, 0, 20), 5.0).solid_geom() + + def tris(model_scale): + m = be.tessellate_stream( + [("c", g)], pipeline="libtess2", deflection=2.0, angular_deg=10.0, model_scale=model_scale + ) + return len(m.indices) // 3 + + base = tris(0.0) # adaptive OFF: fixed 10deg + standalone = tris(500.0) # r_ref=5, cylinder r=5 is a large fraction of the model -> unchanged + tiny_feature = tris(100_000.0) # r_ref=1000, cylinder r=5 is a sub-1% feature -> coarsened + + assert standalone == base, "a part large relative to its model must NOT coarsen" + assert tiny_feature < base, "a tiny feature in a huge model must coarsen" diff --git a/tests/core/cadit/ngeom/test_composite_curve_profile.py b/tests/core/cadit/ngeom/test_composite_curve_profile.py new file mode 100644 index 000000000..64835ed49 --- /dev/null +++ b/tests/core/cadit/ngeom/test_composite_curve_profile.py @@ -0,0 +1,58 @@ +"""Composite-curve profile boundaries + face-based surface models serialize to NGEOM. + +Regression for the trimmed-curve-parameters and surface-model audit OCC fallbacks: + * an ArbitraryClosedProfileDef whose outer curve is an IfcCompositeCurve of trimmed conic / line + segments (curve-parameters-in-degrees/radians), and + * an IfcFaceBasedSurfaceModel of plain polyloop faces (surface-model), +both had no NGEOM path and fell back to OCC. +""" + +from __future__ import annotations + +import math + +import numpy as np + +import ada.geom.curves as cu +from ada.geom.placement import Axis2Placement3D +from ada.geom.points import Point + + +def test_trimmed_line_respects_sense_agreement(): + from ada.cadit.ngeom.serialize import _sample_trimmed_curve + + line = cu.Line(pnt=Point(0, 0, 0), dir=(1.0, 0.0, 0.0)) + fwd = _sample_trimmed_curve(cu.TrimmedCurve(basis_curve=line, trim1=0.0, trim2=2.0, sense_agreement=True)) + rev = _sample_trimmed_curve(cu.TrimmedCurve(basis_curve=line, trim1=0.0, trim2=2.0, sense_agreement=False)) + assert np.allclose(fwd[0], [0, 0, 0]) and np.allclose(fwd[-1], [2, 0, 0]) + assert np.allclose(rev[0], [2, 0, 0]) and np.allclose(rev[-1], [0, 0, 0]) # reversed + + +def test_trimmed_circle_arc_short_way(): + from ada.cadit.ngeom.serialize import _sample_trimmed_curve + + circ = cu.Circle(position=Axis2Placement3D(location=(0, 0, 0)), radius=1.0) + # 0 -> 90deg, sense True -> a quarter arc from (1,0) to (0,1) + pts = np.array(_sample_trimmed_curve(cu.TrimmedCurve(circ, trim1=0.0, trim2=math.pi / 2, sense_agreement=True))) + assert np.allclose(pts[0], [1, 0, 0], atol=1e-6) + assert np.allclose(pts[-1], [0, 1, 0], atol=1e-6) + assert np.allclose(np.linalg.norm(pts, axis=1), 1.0, atol=1e-6) # every sample on the unit circle + + +def test_composite_pie_slice_area(): + from ada.cadit.ngeom.serialize import _composite_curve_loop_points + + circ = cu.Circle(position=Axis2Placement3D(location=(0, 0, 0)), radius=1.0) + lx = cu.Line(pnt=Point(0, 0, 0), dir=(1.0, 0.0, 0.0)) # origin -> (1,0) + ly = cu.Line(pnt=Point(0, 0, 0), dir=(0.0, 1.0, 0.0)) # origin -> (0,1) + cc = cu.CompositeCurve( + segments=[ + cu.CompositeCurveSegment(parent_curve=cu.TrimmedCurve(circ, 0.0, math.pi / 2, sense_agreement=True)), + cu.CompositeCurveSegment(parent_curve=cu.TrimmedCurve(ly, 0.0, 1.0, sense_agreement=False)), # (0,1)->0 + cu.CompositeCurveSegment(parent_curve=cu.TrimmedCurve(lx, 0.0, 1.0, sense_agreement=True)), # 0->(1,0) + ] + ) + p = np.array(_composite_curve_loop_points(cc)) + x, y = p[:, 0], p[:, 1] + area = 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + assert abs(area - math.pi / 4) < 0.01 # quarter unit disk diff --git a/tests/core/cadit/ngeom/test_deserialize_solids.py b/tests/core/cadit/ngeom/test_deserialize_solids.py new file mode 100644 index 000000000..d2afb3fae --- /dev/null +++ b/tests/core/cadit/ngeom/test_deserialize_solids.py @@ -0,0 +1,97 @@ +"""NGEOM deserialize decodes swept/CSG solid tags 50-53 back to ada.geom. + +serialize.py emits ExtrudedAreaSolid/RevolvedAreaSolid/BooleanResult/Sphere as tags 50-53 (with the +profile flattened into a planar FACE), and adacpp's C++ decoder reads them — but the Python +deserializer used to raise on those tags, so the lazy ShapeStore's pure-Python round-trip failed on +any analytic solid. It now inverts them (rebuilding the profile from the face's local-XY loops), so +serialize→deserialize→serialize is byte-identical. Only the baked-frame tag 54 stays lossy (raises). +""" + +from __future__ import annotations + +import math + +import pytest + +import ada.geom.curves as cu +import ada.geom.solids as so +import ada.geom.surfaces as su +from ada import Beam +from ada.geom import Geometry +from ada.geom.booleans import BooleanResult, BoolOpEnum +from ada.geom.direction import Direction +from ada.geom.placement import Axis1Placement, Axis2Placement3D +from ada.geom.points import Point +from ada.cadit.ngeom.deserialize import NgeomDecodeError, deserialize_geometries +from ada.cadit.ngeom.serialize import serialize_geometries + + +def _roundtrip(geom: Geometry): + buf = serialize_geometries([("g", geom)]) + decoded = deserialize_geometries(buf) + assert len(decoded) == 1 + gg = decoded[0][1] + g2 = gg if isinstance(gg, Geometry) else Geometry("g", gg) + # byte-identical re-encode is the strongest round-trip guarantee + assert serialize_geometries([("g", g2)]) == buf + return gg.geometry if isinstance(gg, Geometry) else gg + + +def _rect(w, h): + p = [Point(0, 0), Point(w, 0), Point(w, h), Point(0, h)] + segs = [cu.Edge(p[i], p[(i + 1) % 4]) for i in range(4)] + return su.ArbitraryProfileDef(profile_type=su.ProfileType.AREA, outer_curve=cu.IndexedPolyCurve(segs)) + + +def test_extruded_area_solid_roundtrip(): + """An IPE200 beam's extrusion (I-profile WITH fillet arcs) decodes back to an ExtrudedAreaSolid, + fillets intact (byte-identical re-encode).""" + g = Beam("b", (0, 0, 0), (2, 0, 0), "IPE200").solid_geom() + out = _roundtrip(g) + assert isinstance(out, so.ExtrudedAreaSolid) + + +def test_sphere_solid_roundtrip(): + out = _roundtrip(Geometry("s", so.Sphere(center=Point(0, 0, 0), radius=1.5))) + assert isinstance(out, so.Sphere) + assert out.radius == pytest.approx(1.5) + + +def test_revolved_area_solid_roundtrip(): + """Revolve round-trips through the world→local axis transform + degrees↔radians conversion.""" + rev = so.RevolvedAreaSolid( + swept_area=_rect(1, 2), + position=Axis2Placement3D(Point(0, 0, 0)), + axis=Axis1Placement(Point(3, 0, 0), Direction(0, 0, 1)), + angle=270.0, + ) + out = _roundtrip(Geometry("r", rev)) + assert isinstance(out, so.RevolvedAreaSolid) + assert out.angle == pytest.approx(270.0) + + +def test_boolean_result_roundtrip(): + box = so.Box.from_2points(Point(0, 0, 0), Point(2, 2, 2)) + sph = so.Sphere(center=Point(1, 1, 1), radius=0.8) + out = _roundtrip(Geometry("bool", BooleanResult(first_operand=box, second_operand=sph, operator=BoolOpEnum.DIFFERENCE))) + assert isinstance(out, BooleanResult) + assert out.operator is BoolOpEnum.DIFFERENCE + assert isinstance(out.first_operand, so.ExtrudedAreaSolid) # Box serializes as an extrusion + assert isinstance(out.second_operand, so.Sphere) + + +def test_fixed_reference_swept_is_lossy(): + """Tag 54 bakes the directrix into per-station frames — not invertible → a clear decode error.""" + from ada.cadit.ngeom.serialize import _EXTRUDED_AREA_SOLID # noqa: F401 - ensure module import ok + + # Build a minimal tag-54 buffer by serializing a fixed-ref swept solid if one is constructible; + # otherwise assert the decoder rejects the tag directly. + from ada.cadit.ngeom import deserialize as _d + + dec = _d._Decoder([(_d._FIXED_REF_SWEPT_SOLID, memoryview(b""))]) + with pytest.raises(NgeomDecodeError, match="tag 54"): + dec.get(0) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/core/cadit/ngeom/test_faceted_inferred_plane.py b/tests/core/cadit/ngeom/test_faceted_inferred_plane.py new file mode 100644 index 000000000..65e0283d0 --- /dev/null +++ b/tests/core/cadit/ngeom/test_faceted_inferred_plane.py @@ -0,0 +1,62 @@ +"""Faceted-brep (plain planar Face) shells serialize + tessellate natively via libtess2 — with +inner-bound holes cut and NO vertex displacement. + +Regression for basin-faceted-brep.ifc: + * FacetedBrep / plain Face had no NGEOM dispatch, so the shell fell back to OCC (which capped the + rim's opening). + * Once face_surface inferred a plane per plain Face, the inferred planes were transient and the + id()-keyed surface() memo collided (a GC'd plane's id reused by the next), so faces referenced + the wrong plane record and tessellated to displaced positions — worse the more faces, silently + dropping ~20% of surface area on a real shell. +""" + +from __future__ import annotations + +import gc + +import numpy as np +import pytest + + +def _square_face(dx: float, dy: float, dz: float): + import ada.geom.curves as cu + import ada.geom.surfaces as su + from ada.geom.points import Point + + s = 0.05 + loop = cu.PolyLoop( + polygon=[Point(dx, dy, dz), Point(dx + s, dy, dz), Point(dx + s, dy + s, dz), Point(dx, dy + s, dz)] + ) + return su.Face(bounds=[su.FaceBound(bound=loop, orientation=True)]) # plain Face -> inferred plane + + +def test_faceted_shell_libtess2_no_displacement(): + pytest.importorskip("adacpp") + import ada.geom.surfaces as su + from ada.cad import active_backend + from ada.cadit.ngeom.serialize import serialize_geometries + + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer"): + pytest.skip("active backend has no libtess2 stream") + + # Many small planar faces at distinct locations; GC between builds so a naive id()-memo would + # collide on reused plane ids. + faces = [] + for i in range(120): + faces.append(_square_face(0.3 + 0.1 * (i % 12), 0.25 + 0.1 * (i // 12), 0.001 * (i % 5))) + gc.collect() + cfs = su.ConnectedFaceSet(faces) + + def area(blob): + m = be.tessellate_stream_buffer(bytes(blob), pipeline="libtess2") + v = np.asarray(m.positions, float).reshape(-1, 3) + idx = np.asarray(m.indices, int).reshape(-1, 3) + tv = v[idx] + return 0.5 * np.linalg.norm(np.cross(tv[:, 1] - tv[:, 0], tv[:, 2] - tv[:, 0]), axis=1).sum() + + expected = 120 * 0.05 * 0.05 # every face is a 5cm square + got = area(serialize_geometries([("shell", cfs)])) + # Without the pin fix this drops well below expected (displaced faces lose area); allow only + # tessellation-level slack. + assert got == pytest.approx(expected, rel=1e-3), f"{got} vs {expected}" diff --git a/tests/core/cadit/ngeom/test_serialize.py b/tests/core/cadit/ngeom/test_serialize.py index 1a60add20..4da79e90c 100644 --- a/tests/core/cadit/ngeom/test_serialize.py +++ b/tests/core/cadit/ngeom/test_serialize.py @@ -103,7 +103,9 @@ def test_dependency_order_no_forward_refs(): def _bspline_surface_face(): nu, nv = 4, 6 # 24 control points > _BULK_MIN rows = [[Point(float(i), float(j), float(i * j) * 0.25) for j in range(nv)] for i in range(nu)] - surf = su.BSplineSurfaceWithKnots( + # Rational subclass carries the weights as a real field (the geom dataclasses are + # slotted — ad-hoc attributes on the non-rational class no longer stick). + surf = su.RationalBSplineSurfaceWithKnots( u_degree=3, v_degree=3, control_points_list=rows, @@ -116,15 +118,15 @@ def _bspline_surface_face(): u_knots=[float(i) for i in range(20)], v_knots=[float(i) * 0.5 for i in range(20)], knot_spec=cu.KnotType.UNSPECIFIED, + weights_data=[[1.0 + 0.01 * (i + j) for j in range(nv)] for i in range(nu)], # >16 flat weights ) - surf.weights_data = [[1.0 + 0.01 * (i + j) for j in range(nv)] for i in range(nu)] # >16 flat weights poly = cu.PolyLoop(polygon=[Point(math.cos(0.3 * k), math.sin(0.3 * k), 0.1 * k) for k in range(20)]) # >16 pts return su.FaceSurface(bounds=[su.FaceBound(bound=poly, orientation=True)], face_surface=surf, same_sense=True) def _bspline_curve_edge_face(): cps = [Point(float(i), float(i % 3), 0.0) for i in range(20)] # >16 control points - bc = cu.BSplineCurveWithKnots( + bc = cu.RationalBSplineCurveWithKnots( degree=3, control_points_list=cps, curve_form=cu.BSplineCurveFormEnum.POLYLINE_FORM, @@ -133,8 +135,8 @@ def _bspline_curve_edge_face(): knot_multiplicities=[1] * 24, # >16 multiplicities knots=[float(i) for i in range(24)], # >16 knots knot_spec=cu.KnotType.UNSPECIFIED, + weights_data=[1.0 + 0.001 * i for i in range(20)], # >16 weights (real field; slotted classes) ) - bc.weights_data = [1.0 + 0.001 * i for i in range(20)] # >16 weights s, t = (0.0, 0.0, 0.0), (19.0, 0.0, 0.0) ec = cu.EdgeCurve(start=s, end=t, edge_geometry=bc, same_sense=True) oe = cu.OrientedEdge(start=s, end=t, edge_element=ec, orientation=True) diff --git a/tests/core/cadit/ngeom/test_step2glb_parity_fixes.py b/tests/core/cadit/ngeom/test_step2glb_parity_fixes.py index cb9a09513..dc11988da 100644 --- a/tests/core/cadit/ngeom/test_step2glb_parity_fixes.py +++ b/tests/core/cadit/ngeom/test_step2glb_parity_fixes.py @@ -71,7 +71,17 @@ def total_tris(shape, fname: str) -> int: t = 0 for g in stream_read_step(src, local_pool=False, tolerant=True): gi = g.geometry.geometry if hasattr(g.geometry, "geometry") else g.geometry - t += len(be.tessellate_stream([(str(g.id), gi)], pipeline="libtess2", deflection=2.0).indices) // 3 + # Pin max-angle 20 explicitly: this gate measures step2glb PARITY (its ~20-25deg + # density), independent of the viewer's finer corpus default (DEFAULT_STREAM_TESS_ + # ANGULAR_DEG, now 10deg) which would otherwise ~double the golden counts. + t += ( + len( + be.tessellate_stream( + [(str(g.id), gi)], pipeline="libtess2", deflection=2.0, angular_deg=20.0 + ).indices + ) + // 3 + ) return t golden = {"cylinder": 120, "sphere": 324} diff --git a/tests/core/cadit/ngeom/test_swept_disk_solid.py b/tests/core/cadit/ngeom/test_swept_disk_solid.py new file mode 100644 index 000000000..dbdc52991 --- /dev/null +++ b/tests/core/cadit/ngeom/test_swept_disk_solid.py @@ -0,0 +1,34 @@ +"""IfcSweptDiskSolid (rebar/pipe) serializes + tessellates natively via libtess2 — a circular +profile swept along the directrix — instead of falling back to OCC.""" + +from __future__ import annotations + +import numpy as np +import pytest + + +def test_swept_disk_solid_tube_libtess2(): + pytest.importorskip("adacpp") + import ada.geom.curves as cu + import ada.geom.solids as so + from ada.cad import active_backend + from ada.cadit.ngeom.serialize import serialize_geometries + from ada.geom.points import Point + + be = active_backend() + if not hasattr(be, "tessellate_stream_buffer"): + pytest.skip("no libtess2 stream") + + # straight rod, radius 0.05, length 10 along +x -> lateral area 2*pi*r*L + directrix = cu.IndexedPolyCurve(segments=[cu.Edge(Point(0, 0, 0), Point(10, 0, 0))]) + sds = so.SweptDiskSolid(directrix=directrix, radius=0.05) + blob = serialize_geometries([("rod", sds)]) + assert len(blob) > 32, "SweptDiskSolid serialized empty (would fall back to OCC)" + m = be.tessellate_stream_buffer(bytes(blob), pipeline="libtess2") + v = np.asarray(m.positions, float).reshape(-1, 3) + idx = np.asarray(m.indices, int).reshape(-1, 3) + assert len(idx) > 0 + tv = v[idx] + area = 0.5 * np.linalg.norm(np.cross(tv[:, 1] - tv[:, 0], tv[:, 2] - tv[:, 0]), axis=1).sum() + lateral = 2 * np.pi * 0.05 * 10.0 # ~3.14 ; caps add ~2*pi*r^2 (~0.016) + assert lateral * 0.9 < area < lateral * 1.15, area diff --git a/tests/core/cadit/sat/test_plane_surface_frame.py b/tests/core/cadit/sat/test_plane_surface_frame.py new file mode 100644 index 000000000..6b6e791f6 --- /dev/null +++ b/tests/core/cadit/sat/test_plane_surface_frame.py @@ -0,0 +1,36 @@ +"""ACIS plane-surface / straight-curve records must parse their geometry past the entity header. + +The record header ``$owner -1 -1 $attrib`` has two bare ``-1`` integers that are numeric, so a +plain float-filter swallowed them and shifted origin/normal/u_direction by two — planes came out +with a garbage frame (and the flat plate tessellated to a single triangle via the NGEOM path). +""" + +from __future__ import annotations + +import numpy as np + +import ada + + +def test_plane_surface_frame_not_shifted(example_files): + """plate_1_flat.sat: the planar face reads its true frame (origin ~(20.57,-32.53,3.9), normal + ~(0,0,-1)), not the header-shifted garbage the old parser produced.""" + a = ada.from_acis(example_files / "sat_files/plate_1_flat.sat") + o = list(a.get_all_physical_objects())[0] + faces = getattr(o.geom.geometry, "cfs_faces", None) or getattr(o.geom.geometry, "faces", None) + plane = faces[0].face_surface + pos = plane.position + assert np.allclose(np.asarray(pos.location), (20.569, -32.525, 3.9), atol=0.01), pos.location + n = np.asarray(pos.axis, float) + assert np.allclose(np.abs(n), (0, 0, 1), atol=1e-3), n # unit normal along z (sign is sense-dependent) + + +def test_parse_plane_surface_helper_skips_header(): + """Unit-level: the header-skip helper drops the `$owner -1 -1 $attrib` prefix and reads the + coordinates, not the header integers.""" + from ada.cadit.sat.parser.parser import _numeric_after_header + + parts = "$-1 -1 -1 $-1 20.5 -32.5 3.9 0 0 -1 -1 0 0 reverse_v I I I I #".split() + got = _numeric_after_header(parts) + assert got[0:3] == [20.5, -32.5, 3.9] # origin, not [-1, -1, 20.5] + assert got[3:6] == [0.0, 0.0, -1.0] # normal diff --git a/tests/core/cadit/sat/test_sat_shape_color.py b/tests/core/cadit/sat/test_sat_shape_color.py new file mode 100644 index 000000000..9cbb870f1 --- /dev/null +++ b/tests/core/cadit/sat/test_sat_shape_color.py @@ -0,0 +1,21 @@ +"""SAT-imported shapes must carry a renderable colour, not render black. + +``from_acis`` builds ``Shape(f"shape{i}", Geometry(i, geom))`` with no colour, so +the Geometry's ``color`` is None. The tessellator reads ``geom.color`` (not the +Shape's), so without a fallback the body renders black. ``Shape.solid_geom`` +now syncs the Shape's colour (light-gray by default) onto the Geometry. +""" + +import ada + + +def test_sat_shape_solid_geom_has_color(example_files): + a = ada.from_acis(example_files / "sat_files/curved_plate.sat") + shapes = list(a.get_all_physical_objects()) + assert shapes, "no shapes imported" + for s in shapes: + assert s.color is not None + g = s.solid_geom() + # the Geometry the tessellator meshes must carry the colour too + assert g.color is not None, f"{s.name}: solid_geom colour is None (would render black)" + assert tuple(g.color) == tuple(s.color) diff --git a/tests/core/cadit/step/test_stream_mapped_instances.py b/tests/core/cadit/step/test_stream_mapped_instances.py new file mode 100644 index 000000000..29d3774ec --- /dev/null +++ b/tests/core/cadit/step/test_stream_mapped_instances.py @@ -0,0 +1,94 @@ +"""Non-OCC (ap242_stream) STEP writer preserves multi-instance mapped shapes ANALYTICALLY. + +``mapped-shape-with-multiple-items.ifc`` is one IfcBuildingElementProxy whose Body is four +IfcMappedItems reusing one extruded-solid source under non-uniform-scaled transforms — carried on +``Geometry.transforms``. The OCC ``to_stp`` writer drops all but one (a rigid STEP placement can't +carry the scale). The stream writer emits one solid PER instance, transforming the analytic +Extrusion (exact planar faces + line/arc edges, no tessellation) so the geometry is maintained — a +faceted bake is only a last resort for transforms the analytic form can't carry. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ada +from ada.config import Config + + +@pytest.fixture(autouse=True) +def _enable_geom(): + Config().update_config_globally("ifc_import_shape_geom", True) + + +def _fem_or_none(example_files): + p = example_files / "ifc_files/mapped_shapes/mapped-shape-with-multiple-items.ifc" + return p if p.exists() else None + + +def test_transform_extrusion_analytic_rigid_and_scale(): + """Unit: the analytic Extrusion transform carries a rotation + axis-aligned non-uniform scale + + translation, and refuses an oblique extrude (so the caller facet-falls-back).""" + from ada.cadit.step.write.ap242_stream import Extrusion, Seg, _transform_extrusion + + unit = Extrusion( + origin=(0.0, 0.0, 0.0), + xdir=(1.0, 0.0, 0.0), + normal=(0.0, 0.0, 1.0), + depth=2.0, + outer=[ + Seg("line", (0.0, 0.0), (1.0, 0.0)), + Seg("line", (1.0, 0.0), (1.0, 1.0)), + Seg("line", (1.0, 1.0), (0.0, 1.0)), + Seg("line", (0.0, 1.0), (0.0, 0.0)), + ], + inners=[], + name="u", + color=None, + ) + + # scale x by 0.5, y by 0.25, translate — stays an analytic extrusion + S = np.diag([0.5, 0.25, 1.0, 1.0]).astype(float) + S[:3, 3] = (3.0, 4.0, 0.0) + placed = _transform_extrusion(unit, S) + assert placed is not None + assert np.allclose(placed.origin, (3.0, 4.0, 0.0)) + assert placed.depth == pytest.approx(2.0) + # profile x-extent scaled to 0.5, y-extent to 0.25 + xs = [p[0] for s in placed.outer for p in (s.start, s.end)] + ys = [p[1] for s in placed.outer for p in (s.start, s.end)] + assert max(xs) - min(xs) == pytest.approx(0.5) + assert max(ys) - min(ys) == pytest.approx(0.25) + + # an oblique shear of the extrude direction can't be an axis-aligned extrusion → None (facet) + shear = np.eye(4) + shear[0, 2] = 1.0 # x += z : tilts the extrude vector off the plane normal + assert _transform_extrusion(unit, shear) is None + + +def test_mapped_multiple_items_stream_step_analytic(example_files, tmp_path): + """End-to-end: 4 mapped instances → 4 analytic MANIFOLD_SOLID_BREPs (not triangle soup), + read back as 4 objects, world bbox matching the ifcopenshell oracle.""" + src = _fem_or_none(example_files) + if src is None: + pytest.skip("mapped-shape-with-multiple-items.ifc not in the test corpus") + + a = ada.from_ifc(src) + out = tmp_path / "mapped.step" + a.to_stp(out, writer="stream") + + data = out.read_bytes() + n_solid = data.count(b"MANIFOLD_SOLID_BREP") + n_face = data.count(b"ADVANCED_FACE") + assert n_solid == 4, f"expected 4 instanced solids, got {n_solid}" + # analytic: a handful of exact faces per box (~6), NOT a tessellated triangle mesh (hundreds). + assert n_face <= 40, f"expected exact analytic faces, got {n_face} (looks tessellated)" + + a2 = ada.from_step(out, reader="auto") + objs = list(a2.get_all_physical_objects()) + assert len(objs) == 4, f"round-trip dropped instances: {len(objs)}" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/core/cadit/test_visual_parity_regressions.py b/tests/core/cadit/test_visual_parity_regressions.py new file mode 100644 index 000000000..67ee0c207 --- /dev/null +++ b/tests/core/cadit/test_visual_parity_regressions.py @@ -0,0 +1,68 @@ +"""Cross-format visual-parity regressions from the corpus audit sweep. + +Each file below previously lost geometry in at least one structure-preserving +export (audit run 63 parity phase): + +* revolved / varying-extrusion-path beams: BeamRevolve dropped by the DOM + Genie-XML writer (only the streaming writer chord-fied them) +* FixedReferenceSweptAreaSolid: GradientCurve directrix unbuildable -> + skipped in STEP export +* SAT wire bodies: bare curve shapes routed through a pythonocc-only wire + builder (skipped under the adacpp backend), and the native STEP counter + saw 0 roots in wireframe-only outputs +* SAT b-spline shell: written as a bare IfcClosedShell the reader could not + import back +* IfcTriangulatedFaceSet: no B-rep build existed, so IFC re-export needed an + impossible kernel round-trip and STEP export wrote no solid +* multi-instance IfcMappedItem (mapped-shape-with-multiple-items): 4 non-uniform- + scaled instances of one source solid. The OCC ``to_stp`` writer (the parity's + old STEP leg) collapsed them to 1 — a rigid STEP placement can't carry the + scale — so the parity now writes STEP via the non-OCC stream writer, which + emits one analytic solid per instance. +""" + +import pytest + +from ada.cadit.visual_parity import parity_for_source_file + +PARITY_SOURCES = [ + "ifc_files/beams/beam-revolved-solid.ifc", + "ifc_files/beams/beam-varying-extrusion-paths.ifc", + "ifc_files/fixed-reference-swept-area-solid.ifc", + "sat_files/single_beam_sesam.sat", + "sat_files/bsplinesurfacewithknots.sat", + "fem_files/sesam/xml_all_basic_props.sat", + "ifc_files/bs_samples/tessellation-with-image-texture.ifc", + "ifc_files/bs_samples/column-straight-rectangle-tessellation.ifc", + "ifc_files/mapped_shapes/mapped-shape-with-multiple-items.ifc", +] + + +@pytest.mark.parametrize("rel_path", PARITY_SOURCES) +def test_cross_format_parity(example_files, monkeypatch, rel_path): + monkeypatch.setenv("ADA_IFC_IMPORT_SHAPE_GEOM", "true") + import ada + + ada.config.Config().reload_config() + + res = parity_for_source_file(example_files / rel_path, ("ifc", "xml", "step")) + assert res.consistent, f"{rel_path}: counts={res.counts} mismatches={res.mismatches} errors={res.errors}" + + +def test_empty_source_counts_zero_everywhere(example_files, tmp_path): + """A geometry-less model must count 0 on every leg. The STEP leg regressed + to 1 once (audit run 64): the wireframe-blind native counter fell back to a + reload, and the reloaded empty file materialized one zero-vertex Shape.""" + import ada + from ada.cadit.visual_parity import assembly_element_count, cross_format_parity + + res = cross_format_parity(ada.Assembly("Empty"), ("ifc", "step"), work_dir=tmp_path) + assert res.counts == {"source": 0, "ifc": 0, "step": 0} + assert res.consistent, f"counts={res.counts} mismatches={res.mismatches} errors={res.errors}" + + # And a wire-only STEP must still count its wireframe as 1 (not 0, not a + # zero-vertex phantom): GEOMETRIC_CURVE_SET is a stream-reader root. + a = ada.from_acis(example_files / "sat_files/single_beam_sesam.sat") + out = tmp_path / "wire.step" + a.to_stp(out) + assert assembly_element_count(ada.from_step(out, reader="auto")) == 1 diff --git a/tests/core/fem/test_mesh_faces.py b/tests/core/fem/test_mesh_faces.py index b83e87531..b0f11b992 100644 --- a/tests/core/fem/test_mesh_faces.py +++ b/tests/core/fem/test_mesh_faces.py @@ -80,11 +80,99 @@ def test_object_free_no_plates_built(fem_files): assert len(list(a.get_all_physical_objects(by_type=ada.Plate))) == 0 -def test_surface_and_panel_not_yet_implemented(fem_files): +def _tube(nseg=24, nrows=12, r=0.5, length=4.0, t=0.01): + """A quad-meshed cylinder along z — exercises the analytic cylinder detection.""" + from ada import Node + + p = ada.Part("tube") + mat = ada.Material("S355") + grid: dict = {} + nid = 1 + for iz in range(nrows + 1): + for ia in range(nseg): + ang = 2.0 * np.pi * ia / nseg + grid[(ia, iz)] = Node([r * np.cos(ang), r * np.sin(ang), length * iz / nrows], nid) + p.fem.nodes.add(grid[(ia, iz)]) + nid += 1 + eid = 1 + Q = ada.fem.Elem.EL_TYPES.SHELL_SHAPES.QUAD + for iz in range(nrows): + for ia in range(nseg): + a_, b_ = grid[(ia, iz)], grid[((ia + 1) % nseg, iz)] + c_, d_ = grid[((ia + 1) % nseg, iz + 1)], grid[(ia, iz + 1)] + el = p.fem.add_elem(ada.fem.Elem(eid, [a_, b_, c_, d_], Q, el_formulation_override="S4")) + el.fem_sec = p.fem.add_section( + ada.fem.FemSection(f"S{eid}", "shell", ada.fem.FemSet(f"s{eid}", [el]), mat, thickness=t) + ) + eid += 1 + a = ada.Assembly() / p + p.fem.sections.merge_by_properties() + return a + + +def test_surface_strategy_flat_mesh_matches_coplanar(fem_files): + # A mesh with no recognisable curved patches: SURFACE degrades to the flat merge. a = ada.from_fem(fem_files / "sesam/beamMassT1.FEM") - for strat in (MergeStrategy.SURFACE, MergeStrategy.PANEL): - with pytest.raises(NotImplementedError): - list(iter_faces(a, strat)) + surf = list(iter_faces(a, MergeStrategy.SURFACE)) + cop = list(iter_faces(a, MergeStrategy.COPLANAR)) + assert all(f.geom_face is None for f in surf) + assert len(surf) == len(cop) + assert _total_area(surf) == pytest.approx(_total_area(cop), rel=1e-9) + + +def test_surface_strategy_detects_cylinder(): + a = _tube() + part = next(p_ for p_ in a.get_all_parts_in_assembly(include_self=True) if p_.fem is not None and len(p_.fem.elements)) + faces = list(iter_faces(part, MergeStrategy.SURFACE)) + analytic = [f for f in faces if f.geom_face is not None] + # the 288-quad tube collapses to a handful of analytic cylinder faces + assert 1 <= len(analytic) <= 8 + assert all(f.thickness == pytest.approx(0.01) and f.material == "S355" for f in analytic) + # polygon-only consumers (Genie XML) must get flats instead — never lose geometry + flats = list(iter_faces(part, MergeStrategy.SURFACE, allow_analytic=False)) + assert all(f.geom_face is None for f in flats) + assert len(flats) >= 1 + + # PANEL includes the cylinder pass too + panel = [f for f in iter_faces(part, MergeStrategy.PANEL) if f.geom_face is not None] + assert len(panel) >= 1 + + +def test_surface_strategy_object_stream_yields_curved_plates(): + a = _tube() + part = next(p_ for p_ in a.get_all_parts_in_assembly(include_self=True) if p_.fem is not None and len(p_.fem.elements)) + objs = list(part.iter_objects_from_fem(beams=False, plates=True, merge_strategy="surface")) + curved = [o for o in objs if isinstance(o, ada.PlateCurved)] + assert 1 <= len(curved) <= 8 + assert len(objs) < 20 # 288 shells -> a handful of concept objects + # each curved plate tessellates on the active backend (renderable, not just writable) + from ada.occ.tessellating import BatchTessellator + + bt = BatchTessellator() + for o in curved: + (ms,) = list(bt.batch_tessellate([o])) + assert len(ms.position) > 0 + + +def test_analytic_cylinder_ifc_streaming_roundtrips(tmp_path, monkeypatch): + # The streaming IFC B-rep emitter must carry the joint-cut cylinder faces' UV + # p-curves (IfcSurfaceCurve/IfcPcurve); without them the trimmed CYLINDRICAL_SURFACE + # face can't rebuild its wire on re-import ('wire build failed') and the shell drops. + monkeypatch.setenv("ADA_IFC_IMPORT_SHAPE_GEOM", "true") + ada.config.Config().reload_config() + + a = _tube() + out = tmp_path / "tube.ifc" + a.to_ifc(out, streaming=True, merge_strategy="cylinder") + txt = out.read_text(errors="ignore").upper() + assert txt.count("IFCPCURVE(") > 0 + assert txt.count("IFCCYLINDRICALSURFACE(") > 0 + + b = ada.from_ifc(out) + scene = b.to_trimesh_scene() + tris = sum(g.faces.shape[0] for g in scene.geometry.values() if hasattr(g, "faces")) + # the re-imported cylinder tessellates to a real curved mesh, not a degenerate one + assert tris > 100 def _part_with_fem(fem_files): diff --git a/tests/core/geom/solids/test_swept_area_solids.py b/tests/core/geom/solids/test_swept_area_solids.py index 3295451a6..3662da4be 100644 --- a/tests/core/geom/solids/test_swept_area_solids.py +++ b/tests/core/geom/solids/test_swept_area_solids.py @@ -48,8 +48,11 @@ def test_prim_sweep2(tmp_path): # sweep.show() - expected = np.asarray([0.622505, 0.415516, 0.808411]) - np.testing.assert_allclose(mesh.center_mass, expected, atol=0.0001) + # Center of mass of the tessellated sweep. atol is 1e-3 (not tighter) because the + # exact value depends on the tessellation density — the angular deflection controls + # how finely the swept arc is sampled, so a quality change nudges it at the 1e-4 level. + expected = np.asarray([0.622645, 0.415708, 0.808493]) + np.testing.assert_allclose(mesh.center_mass, expected, atol=1e-3) def test_prim_sweep_flipped_normals(tmp_path): diff --git a/tests/core/geom/test_polygonal_face_set.py b/tests/core/geom/test_polygonal_face_set.py index 885c49e02..27fa6faf7 100644 --- a/tests/core/geom/test_polygonal_face_set.py +++ b/tests/core/geom/test_polygonal_face_set.py @@ -60,3 +60,38 @@ def test_polygonal_face_set_ifc_roundtrip(): assert read_back.closed is True # First coordinate survives the round-trip. assert read_back.coordinates[0].is_equal(pfs.coordinates[0]) + + +def test_polygonal_face_set_shape_tessellates(example_files): + """An IfcPolygonalFaceSet product (polygonal-face-tessellation.ifc) must import AND render. + Shape.solid_geom previously rejected PolygonalFaceSet (not in its accepted-types list), so + the tessellator skipped it -> empty scene. Also checks the NGEOM libtess2 production path.""" + import os + + import trimesh + + import ada + + a = ada.from_ifc(example_files / "ifc_files/bs_samples/polygonal-face-tessellation.ifc") + shape = next(iter(a.get_all_physical_objects())) + # solid_geom must not raise for a PolygonalFaceSet geometry. + assert isinstance(shape.solid_geom().geometry, geo_su.PolygonalFaceSet) + + def _faces(**env): + for k, v in env.items(): + os.environ[k] = v + try: + sc = a.to_trimesh_scene(merge_meshes=True) + finally: + for k in env: + os.environ.pop(k, None) + tris = [g for g in sc.geometry.values() if hasattr(g, "faces")] + return trimesh.util.concatenate(tris) if tris else None + + for label, env in (("occ", {}), ("libtess2", {"ADA_STREAM_TESS_PIPELINE": "libtess2"})): + m = _faces(**env) + assert m is not None and len(m.faces) > 0, f"{label}: PolygonalFaceSet produced no geometry" + # 20-cube (bbox -10..10) with an inner cavity; both paths agree, outward winding. + lo, hi = m.vertices.min(0), m.vertices.max(0) + assert lo.min() < -9.9 and hi.max() > 9.9 + assert m.volume > 0, f"{label}: inside-out ({m.volume})" diff --git a/tests/core/occ/test_faceted_face_hole.py b/tests/core/occ/test_faceted_face_hole.py new file mode 100644 index 000000000..0d784ff46 --- /dev/null +++ b/tests/core/occ/test_faceted_face_hole.py @@ -0,0 +1,43 @@ +"""A planar (faceted-brep / IfcFace) face with an inner bound must cut a hole, not cap it. + +Regression for the ``basin-faceted-brep.ifc`` cap: the top rim face carries an +``IfcFaceOuterBound`` plus an ``IfcFaceBound`` (the mouth opening). The shell builder used only +``bounds[0]`` and dropped the inner bound, rendering the opening as a solid cap. ``Face`` faces now +build every bound, subtracting the inner ones as holes. +""" + +from __future__ import annotations + +import ada.geom.curves as cu +import ada.geom.surfaces as su +from ada.geom.points import Point + + +def _square(size: float, off: float = 0.0) -> cu.PolyLoop: + return cu.PolyLoop( + polygon=[ + Point(off, off, 0.0), + Point(off + size, off, 0.0), + Point(off + size, off + size, 0.0), + Point(off, off + size, 0.0), + ] + ) + + +def test_planar_face_inner_bound_is_a_hole(): + from ada.occ.geom.surfaces import _face_area, make_planar_face_from_bounds + + # 10x10 outer with a centred 4x4 hole -> area 100 - 16 = 84. + bounds = [ + su.FaceBound(bound=_square(10.0), orientation=True), + su.FaceBound(bound=_square(4.0, off=3.0), orientation=True), + ] + face = make_planar_face_from_bounds(bounds) + assert abs(_face_area(face) - 84.0) < 1e-6, _face_area(face) + + +def test_planar_face_single_bound_unchanged(): + from ada.occ.geom.surfaces import _face_area, make_planar_face_from_bounds + + face = make_planar_face_from_bounds([su.FaceBound(bound=_square(10.0), orientation=True)]) + assert abs(_face_area(face) - 100.0) < 1e-6, _face_area(face) diff --git a/tests/core/occ/test_geom_transforms.py b/tests/core/occ/test_geom_transforms.py new file mode 100644 index 000000000..4e543bdfa --- /dev/null +++ b/tests/core/occ/test_geom_transforms.py @@ -0,0 +1,58 @@ +"""Geometry.transforms → mesh-level instancing. + +Regression for the IfcMappedItem OCC fallbacks: a mapped item with a non-rigid (scale/shear) +transform, or a product that instances one mapping source N times, carries its placement(s) on +``Geometry.transforms`` and is baked into the tessellated mesh — one emitted MeshStore per +transform — instead of falling back to the OCC kernel. +""" + +from __future__ import annotations + +import numpy as np + +from ada.geom import Geometry +from ada.geom.points import Point +from ada.geom.solids import Box +from ada.visit.gltf.meshes import MeshStore, MeshType + + +class _Obj: + def __init__(self, transforms): + self.geom = Geometry("g", Box.from_2points(Point(0, 0, 0), Point(1, 1, 1)), transforms=transforms) + + +def _unit_ms(): + # one triangle at unit scale + pos = np.array([0, 0, 0, 1, 0, 0, 0, 1, 0], dtype=np.float32) + idx = np.array([0, 1, 2], dtype=np.uint32) + return MeshStore(0, None, pos, idx, None, 0, MeshType.TRIANGLES, 0) + + +def test_no_transforms_passthrough(): + from ada.occ.tessellating import _emit_with_geom_transforms + + ms = _unit_ms() + out = list(_emit_with_geom_transforms(ms, _Obj(None))) + assert len(out) == 1 and out[0] is ms + + +def test_nonuniform_scale_baked_into_positions(): + from ada.occ.tessellating import _emit_with_geom_transforms + + m = np.diag([2.0, 3.0, 1.0, 1.0]) # non-uniform scale + out = list(_emit_with_geom_transforms(_unit_ms(), _Obj([m]))) + assert len(out) == 1 + p = np.asarray(out[0].position, float).reshape(-1, 3) + assert np.allclose(p[1], [2, 0, 0]) and np.allclose(p[2], [0, 3, 0]) + + +def test_multiple_transforms_emit_one_meshstore_each(): + from ada.occ.tessellating import _emit_with_geom_transforms + + t1 = np.eye(4) + t2 = np.eye(4) + t2[:3, 3] = [10, 0, 0] # translate + out = list(_emit_with_geom_transforms(_unit_ms(), _Obj([t1, t2]))) + assert len(out) == 2 + p2 = np.asarray(out[1].position, float).reshape(-1, 3) + assert np.allclose(p2[0], [10, 0, 0]) diff --git a/tests/core/occ/test_mesh_distortion.py b/tests/core/occ/test_mesh_distortion.py new file mode 100644 index 000000000..ec8eaf27e --- /dev/null +++ b/tests/core/occ/test_mesh_distortion.py @@ -0,0 +1,51 @@ +"""The crows-nest distortion metric flags spike triangles (a vertex shot out past the body) but NOT +benign geometry — uniform faceting, deep thin extrusions, coarse curves — that a plain aspect/reach +test over-flags. Mirrors the frontend meshStats spike metric behind the "distorted" gallery walk. +""" + +from __future__ import annotations + +import numpy as np + +from ada.occ.tessellating import accumulate_mesh_distortion, consume_mesh_distortion_stats + + +def _grid(n: int = 11): + xs, ys = np.meshgrid(np.linspace(0, 1, n), np.linspace(0, 1, n)) + v = np.c_[xs.ravel(), ys.ravel(), np.zeros(n * n)] + tris = [] + for r in range(n - 1): + for c in range(n - 1): + i = r * n + c + tris += [[i, i + 1, i + n + 1], [i, i + n + 1, i + n]] + return v, np.asarray(tris) + + +def test_clean_grid_has_no_distortion(): + v, tris = _grid() + accumulate_mesh_distortion(v, tris) + assert consume_mesh_distortion_stats()["distorted_tris"] == 0 + + +def test_spike_vertex_is_flagged(): + v, tris = _grid() + v2 = np.vstack([v, [0.5, 0.5, 20.0]]) # a vertex shot 20 units off a unit-size sheet + sp = len(v2) - 1 + tris2 = np.vstack([tris, [[0, 1, sp]]]) # a thin triangle reaching out to it + accumulate_mesh_distortion(v2, tris2) + assert consume_mesh_distortion_stats()["distorted_tris"] == 1 + + +def test_deep_thin_extrusion_not_flagged(): + # A long thin box: side triangles are thin AND reach across the bbox, but no vertex is an + # outlier (they're all on the body) — must NOT count as crows-nest distortion. + x, y, z = 0.05, 0.05, 5.0 + corners = np.array( + [[0, 0, 0], [x, 0, 0], [x, y, 0], [0, y, 0], [0, 0, z], [x, 0, z], [x, y, z], [0, y, z]], float + ) + faces = [ + [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], + [3, 0, 4], [3, 4, 7], [0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7], + ] + accumulate_mesh_distortion(corners, np.asarray(faces)) + assert consume_mesh_distortion_stats()["distorted_tris"] == 0 diff --git a/tests/core/occ/test_mesh_distortion_scan.py b/tests/core/occ/test_mesh_distortion_scan.py new file mode 100644 index 000000000..29cc74bf3 --- /dev/null +++ b/tests/core/occ/test_mesh_distortion_scan.py @@ -0,0 +1,38 @@ +"""The per-conversion distortion tally flags degenerate/sliver triangles (the audit "distorted +tris" flag) and the fallback counter tallies libtess2->OCC fallbacks.""" + +from __future__ import annotations + +import numpy as np + + +def test_distortion_counts_slivers_only(): + from ada.occ.tessellating import accumulate_mesh_distortion, consume_mesh_distortion_stats + + consume_mesh_distortion_stats() # reset + pos = np.array( + [ + [0, 0, 0], [1, 0, 0], [0, 1, 0], # healthy right triangle + [0, 0, 0], [10, 0, 0], [5, 1e-4, 0], # extreme sliver + [0, 0, 0], [1, 0, 0], [1, 0, 0], # degenerate (repeated vertex) + ], + float, + ) + idx = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]], int) + accumulate_mesh_distortion(pos, idx) + s = consume_mesh_distortion_stats() + assert s["n_tris"] == 3 + assert s["distorted_tris"] == 2 # sliver + degenerate, not the healthy one + assert consume_mesh_distortion_stats()["n_tris"] == 0 # reset on read + + +def test_fallback_counter_reset_on_consume(): + from ada.occ.tessellating import _record_tess_fallback, consume_tess_fallback_stats + + consume_tess_fallback_stats() # reset + _record_tess_fallback("empty mesh (geom type not NGEOM-serializable)", "FacetedBrep") + _record_tess_fallback("active backend has no tessellate_stream", "Box") + s = consume_tess_fallback_stats() + assert s["count"] == 2 + assert s["geoms"]["FacetedBrep"] == 1 + assert consume_tess_fallback_stats()["count"] == 0 diff --git a/tests/core/occ/test_polygonal_face_set_direct.py b/tests/core/occ/test_polygonal_face_set_direct.py new file mode 100644 index 000000000..89ed66cd6 --- /dev/null +++ b/tests/core/occ/test_polygonal_face_set_direct.py @@ -0,0 +1,34 @@ +"""IfcPolygonalFaceSet emits directly as a fan-triangulated 3D mesh (no OCC fallback, no plane +flattening). + +Regression for the alignment/signal OCC fallbacks: PolygonalFaceSet had no NGEOM encoder, so it fell +back to OCC. It now fan-triangulates in 3D via _direct_triangulated_meshstore — keeping the original +vertices (a per-face plane inference would flatten non-planar faces). +""" + +from __future__ import annotations + +import numpy as np + +import ada.geom.surfaces as su +from ada.geom import Geometry +from ada.geom.points import Point + + +def test_polygonal_face_set_fan_triangulates_in_3d(): + from ada.occ.tessellating import BatchTessellator + + # Two faces sharing an edge: a quad (-> 2 tris) and a triangle (-> 1 tri). Non-planar quad to + # prove the vertices aren't flattened onto a plane. + coords = [ + Point(0, 0, 0), Point(1, 0, 0), Point(1, 1, 0.3), Point(0, 1, 0), # quad (non-planar: z=0.3) + Point(2, 0, 0), # extra vertex for the triangle + ] + pfs = su.PolygonalFaceSet(coordinates=coords, faces=[[1, 2, 3, 4], [2, 5, 3]]) + ms = BatchTessellator()._direct_triangulated_meshstore(Geometry("p", pfs, None), "n") + assert ms is not None + idx = np.asarray(ms.indices, int).reshape(-1, 3) + assert len(idx) == 3 # 2 (quad fan) + 1 (triangle) + pos = np.asarray(ms.position, float).reshape(-1, 3) + # Original vertices preserved (not projected onto a plane) — vertex 3 keeps its z=0.3. + assert np.isclose(pos[2, 2], 0.3), pos[2] diff --git a/tests/core/visit/test_stream_adaptive_tess.py b/tests/core/visit/test_stream_adaptive_tess.py new file mode 100644 index 000000000..055214591 --- /dev/null +++ b/tests/core/visit/test_stream_adaptive_tess.py @@ -0,0 +1,50 @@ +"""The streaming STEP->GLB libtess2 path honours adaptive tessellation coarsening +(ADA_STREAM_TESS_MODEL_SCALE) — small features in a large model tessellate coarser, so a big +assembly (e.g. the boiler) doesn't over-tessellate every small pipe/torus into a slivery crows-nest. + +Regression for scene_from_step_stream, which previously tessellated at a fixed angular ceiling with +no model_scale (unlike native_step_to_glb, which already coarsens by default). +""" + +from __future__ import annotations + +import json +import os +import struct + +import pytest + + +def _glb_tris(path: str) -> int: + d = open(path, "rb").read() + jlen = struct.unpack(" features << model scale + objs = [ada.PrimCyl(f"c{i}", (i * 2000, 0, 0), (i * 2000, 0, 200), 15) for i in range(5)] + step = tmp_path / "cyls.stp" + (ada.Assembly("t") / (ada.Part("p") / objs)).to_stp(str(step)) + + monkeypatch.setenv("ADA_STREAM_TESS_PIPELINE", "libtess2") + + monkeypatch.setenv("ADA_STREAM_TESS_MODEL_SCALE", "0") # adaptive off + convert_step_stream_to_glb(StepStreamSource(str(step)), str(tmp_path / "off.glb")) + fine = _glb_tris(str(tmp_path / "off.glb")) + + monkeypatch.setenv("ADA_STREAM_TESS_MODEL_SCALE", "20000") # features << 1% => coarsened + convert_step_stream_to_glb(StepStreamSource(str(step)), str(tmp_path / "on.glb")) + coarse = _glb_tris(str(tmp_path / "on.glb")) + + assert fine > 0 and coarse > 0 + assert coarse < fine * 0.5, f"adaptive coarsening had no effect: {coarse} vs {fine}"