From 54c9a249510a7dbd86c02a5e86c4cf2185d7785d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:02:54 +0000 Subject: [PATCH 1/4] chore(deps): align Angular framework and tools; synchronize thumbnail Vitest Combines the intent of #270, #271, #274 and #261 without merging divergent dependency trees. No runtime API changes or publication. --- apps/component-demo/test/angular-pptx/package.json | 14 +++++++------- packages/thumbnail/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/component-demo/test/angular-pptx/package.json b/apps/component-demo/test/angular-pptx/package.json index 1470676e5..47c09387a 100644 --- a/apps/component-demo/test/angular-pptx/package.json +++ b/apps/component-demo/test/angular-pptx/package.json @@ -7,10 +7,10 @@ "build": "ng build" }, "dependencies": { - "@angular/common": "22.0.7", - "@angular/compiler": "22.0.7", - "@angular/core": "22.0.7", - "@angular/platform-browser": "22.0.7", + "@angular/common": "22.1.6", + "@angular/compiler": "22.1.6", + "@angular/core": "22.1.6", + "@angular/platform-browser": "22.1.6", "@file-viewer/web": "3.0.3", "@file-viewer/preset-office": "3.0.3", "file-viewer-copy-assets": "3.0.3", @@ -18,9 +18,9 @@ "tslib": "2.8.1" }, "devDependencies": { - "@angular/build": "22.0.7", - "@angular/cli": "22.0.7", - "@angular/compiler-cli": "22.0.7", + "@angular/build": "22.1.6", + "@angular/cli": "22.1.6", + "@angular/compiler-cli": "22.1.6", "typescript": "6.0.3" } } diff --git a/packages/thumbnail/package.json b/packages/thumbnail/package.json index a5a3fd87e..3d3fc539f 100644 --- a/packages/thumbnail/package.json +++ b/packages/thumbnail/package.json @@ -53,7 +53,7 @@ "esbuild": "^0.28.1", "happy-dom": "^20.11.15", "typescript": "^6.0.3", - "vitest": "3.2.7" + "vitest": "4.1.11" }, "license": "Apache-2.0" } From 5fbbf5bb575c94fc7c15ce4698ae9f5ebfc46026 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:17:03 +0000 Subject: [PATCH 2/4] feat(ifc): integrate bounded advanced settings and pre-model lifecycle hook Preserves the advanced configuration direction from p4535992 in #275 on the #276 owned-worker implementation. Includes original-model Worker behavior, pre-load cancellation and cleanup-failure tests, coordinated dependency guards and complete issue triage. No package publication. --- .github/dependabot.yml | 26 ++ .github/scripts/dependency-cohort.test.mjs | 49 ++++ docs/maintenance/pr-issue-review-20260912.md | 70 +++++ .../issue-267/advanced/report.json | 262 ++++++++++++++++++ packages/renderers/3d/IFC.md | 52 ++++ packages/renderers/3d/package.json | 2 +- .../3d/scripts/ifc-settings.test.mjs | 140 ++++++++++ .../3d/scripts/verify-ifc-browser.mjs | 180 +++++++++++- .../renderers/3d/src/ifc-import.worker.ts | 10 +- packages/renderers/3d/src/ifc.ts | 17 ++ packages/renderers/3d/src/ifcRuntime.ts | 89 ++++-- packages/renderers/3d/src/ifcSettings.ts | 185 +++++++++++++ 12 files changed, 1052 insertions(+), 30 deletions(-) create mode 100644 .github/scripts/dependency-cohort.test.mjs create mode 100644 docs/maintenance/pr-issue-review-20260912.md create mode 100644 docs/regressions/issue-267/advanced/report.json create mode 100644 packages/renderers/3d/scripts/ifc-settings.test.mjs create mode 100644 packages/renderers/3d/src/ifcSettings.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d2eafeab3..032218a6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,6 +36,32 @@ updates: # pako 3 changes Inflate err/msg semantics used by the DICOM deflated-study # path and needs a licence ledger refresh, so its majors stay deferred. + # The cold consumer is outside the workspace. Framework and compiler peers + # must move together rather than producing individually incompatible PRs. + - package-ecosystem: npm + directory: /apps/component-demo/test/angular-pptx + schedule: + interval: weekly + day: monday + time: '03:10' + timezone: Asia/Shanghai + open-pull-requests-limit: 3 + groups: + angular-version-cohort: + applies-to: version-updates + patterns: + - '@angular/*' + angular-security-cohort: + applies-to: security-updates + patterns: + - '@angular/*' + commit-message: + prefix: chore(deps) + ignore: + - dependency-name: '@angular/*' + update-types: + - version-update:semver-major + - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/scripts/dependency-cohort.test.mjs b/.github/scripts/dependency-cohort.test.mjs new file mode 100644 index 000000000..6305db5b0 --- /dev/null +++ b/.github/scripts/dependency-cohort.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +const read = (path) => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8') +function angularCohort(manifest) { + const dependencies = { ...manifest.dependencies, ...manifest.devDependencies } + for (const name of [ + 'common', + 'compiler', + 'core', + 'platform-browser', + 'build', + 'cli', + 'compiler-cli' + ]) + assert.ok(dependencies[`@angular/${name}`], `Missing Angular cohort member: ${name}`) + const versions = Object.entries(dependencies) + .filter(([name]) => name.startsWith('@angular/')) + .map(([, version]) => version) + assert.equal(new Set(versions).size, 1, 'Angular framework and tooling must use one exact cohort') + assert.match(versions[0], /^\d+\.\d+\.\d+$/, 'Angular fixture must pin stable exact versions') +} +const fixture = JSON.parse(read('apps/component-demo/test/angular-pptx/package.json')) +test('cold Angular consumer pins a complete aligned cohort', () => angularCohort(fixture)) +test('a single-package Angular bump fails before installation', () => { + const changed = structuredClone(fixture) + changed.dependencies['@angular/core'] = '0.0.0' + assert.throws(() => angularCohort(changed), /one exact cohort/) +}) +test('thumbnail manifest agrees with workspace Vitest security override', () => { + const thumbnail = JSON.parse(read('packages/thumbnail/package.json')) + const override = read('pnpm-workspace.yaml').match(/^ vitest: (\S+)$/m)?.[1] + assert.ok(override) + assert.equal(thumbnail.devDependencies.vitest, override) +}) +test('Dependabot groups version and security Angular updates in the nested fixture', () => { + const entry = read('.github/dependabot.yml') + .split(' - package-ecosystem: npm') + .find((part) => part.includes('directory: /apps/component-demo/test/angular-pptx')) + assert.ok(entry) + assert.match( + entry, + /angular-version-cohort:\s+applies-to: version-updates\s+patterns:\s+- '@angular\/\*'/ + ) + assert.match( + entry, + /angular-security-cohort:\s+applies-to: security-updates\s+patterns:\s+- '@angular\/\*'/ + ) +}) diff --git a/docs/maintenance/pr-issue-review-20260912.md b/docs/maintenance/pr-issue-review-20260912.md new file mode 100644 index 000000000..151bcd2d0 --- /dev/null +++ b/docs/maintenance/pr-issue-review-20260912.md @@ -0,0 +1,70 @@ +# PR and issue review — 2026-09-12 + +Scope: all six open PRs and seven open issues in `flyfish-dev/file-viewer`, plus +related DOCX/CAD/spreadsheet upstreams. Source maintenance only: no npm release, +version bump, release tag or automatic issue closure. + +## PR disposition + +| PR | Decision | +| --- | --- | +| #276 | Merged as `db73a732e4978f2e4d0d573cd0ed7333690fe461`. Exact-head Public CI `34625940086`, Security `34625940222` and corrected PR evidence gate `34679546125` passed. No governance rules were weakened. | +| #275 | Incorporate @p4535992's advanced-configuration direction (comment `5638194837`) on #276's stronger owned-Worker foundation. This change adds data-only importer/Fragments settings and a pre-model runtime hook. Do not merge the separate draft capability/assets packages or duplicate runtime. | +| #270 / #271 / #274 | Combine upgrade intent into the complete **Angular 22.1.6** framework/tooling cohort: common, compiler, core, platform-browser, build, CLI and compiler-cli. Registry metadata confirms matching published versions and exact framework peers. Individual 22.1.0/22.1.1 PRs are superseded by this coordinated change. | +| #261 | Synchronize the thumbnail manifest to **Vitest 4.1.11**, already selected by the workspace security override. Real thumbnail tests verify the result; the runtime was not actually on Vitest 3 before this manifest correction. | + +Qualification `34679652404` passed real lock generation, frozen installation, +core/thumbnail builds, thumbnail tests, governance and public-release facts. A +clean Angular consumer passed npm installation, peer-tree validation and `ng build`. +The lockfile required no byte change because Vitest was already overridden and the +Angular fixture is outside the workspace. The full consolidated CI additionally +runs packed Angular browser consumers and all existing rendering/framework gates. + +Prevent recurrence: group nested `@angular/*` version and security updates; +deterministic tests reject split framework/tooling versions and thumbnail pin drift. +The IFC gate verifies actual Worker settings effects, original IFC4/IFC4.3 geometry, +picking, reverse-order cleanup and cancellation, rather than only checking types. + +## Issue follow-up + +All seven issue bodies and available comments were re-read. The table distinguishes +source inclusion, publication and original-report acceptance. No missing sample is +silently replaced by a synthetic fixture, and issues stay open pending acceptance. + +| Issue | Evidence / next acceptance condition | +| --- | --- | +| #227 — XLS undefined name | Original sensitive XLS remains unavailable in the thread. WPS re-saving is a workaround, not root-cause proof. Require a sanitized failing file or dated private receipt; MiniFAT fixtures alone do not prove this report fixed. | +| #248 — Vue CLI DOCX/XLS | Latest comment supplies an XLS screenshot, not a project/file. Existing cold Vue CLI tests do not prove the reporter's exact integration. Require lockfile, minimal project, original bytes and failing console/Worker requests. | +| #266 — Word/OFD fidelity | Original-sample repairs are in #273/#276 and upstream docxjs#10. Diagonal source is merged, but npm `@file-viewer/docx` was still **0.3.31** at review. Pending upstream publication and downstream dependency/Worker/lock synchronization. | +| #267 — IFC | Optional viewer foundation is merged; this change incorporates the advanced configuration request. Scope is local visualization/inspection, not full BIM authoring or a promise of arbitrary large-model performance. Preserve self-hosted assets and license notices. | +| #268 — PPTX charts/tables | Reporter supplied `default.pptx` in comment `5628266590`; original-file repairs/evidence are in merged #272. Pending delivery of a new File Viewer package and reporter confirmation, not a claim that the public package is already updated. | +| #269 — CAD Chinese text | Thread still lacks original CAD file, font resources and usable environment/version details. Need original DWG/DXF, SHX/TTF mapping and failing font/network requests. Screenshot alone cannot distinguish encoding from missing fonts. | +| #277 — binary inspector | Separately scoped optional read-only feature proposal, not implemented in this release. Acceptance should require virtual hex/ASCII, bounded terminable parsing, allowlisted build-time templates with per-template license review, and explicit routing that cannot steal dedicated renderers. Editing/arbitrary executable templates are out of initial scope. | + +Related upstreams: `flyfish-dev/docxjs` had no open PRs/issues; #10 is merged as +`6dbe15e347459f3707116d531fc9064f2d4c2a95`. The live CAD and styled-exceljs upstream +snapshots likewise had no open items. + +## Release handoff + +After the maintainer publishes the reviewed upstream DOCX version, run: + +```sh +pnpm release:prepare-docx +pnpm release:verify +git diff --check +``` + +Review and commit synchronized dependency, runtime/Worker and lockfile metadata. +The actual-installed-engine behavioral gate must pass before File Viewer release. +Do not publish with the old DOCX dependency simply because source CI is green. + +## Lifecycle defect caught during integration + +The first real-browser advanced-hook qualification (`34680017521`) passed both +original models and actual importer settings, then failed because a pre-model +cancellation left a Worker alive. Upstream `abort(id)` creates a connection for an +unknown model ID. The adapter now calls it only for a registered model; disposal +before model loading must not create a new Worker. Regression coverage includes +late asynchronous runtime hooks, invalid Fragments settings before load, and a +throwing host cleanup without suppressing remaining hook/Worker/WebGL disposal. diff --git a/docs/regressions/issue-267/advanced/report.json b/docs/regressions/issue-267/advanced/report.json new file mode 100644 index 000000000..ea3ba936a --- /dev/null +++ b/docs/regressions/issue-267/advanced/report.json @@ -0,0 +1,262 @@ +{ + "entryBytes": 633, + "cases": [ + { + "name": "ifc4.ifc", + "count": 13, + "first": 319, + "selected": { + "localId": 319, + "name": "sand bedding", + "globalId": "3_4VN63S96DfWiJjgG8j1C", + "entityType": "IFCBUILDINGELEMENTPROXY" + }, + "picked": 201, + "drawn": { + "calls": 7, + "triangles": 1143 + }, + "milliseconds": 1448, + "workers": { + "created": 2, + "active": 0 + } + }, + { + "name": "ifc43.ifc", + "count": 13, + "first": 335, + "selected": { + "localId": 335, + "name": "origin", + "globalId": "2F44QMqSH3TOkM$SZoqCBe", + "entityType": "IFCBUILDINGELEMENTPROXY" + }, + "picked": 201, + "drawn": { + "calls": 8, + "triangles": 1143 + }, + "milliseconds": 930, + "workers": { + "created": 4, + "active": 0 + } + } + ], + "advanced": { + "count": 13, + "name": "", + "rate": 80, + "runtimeHadModels": 0, + "order": [ + "model", + "runtime" + ], + "cleanupCount": 1 + }, + "invalidSettings": true, + "unknownSettings": true, + "lateRuntimeCleanup": { + "aborted": true, + "cleanups": 1 + }, + "badFragments": true, + "throwingCleanup": { + "rejected": true, + "order": [ + "model", + "runtime" + ] + }, + "requests": [ + { + "method": "GET", + "url": "http://127.0.0.1:45013/" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/app/ifc.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/app/chunk-WOT6VMZA.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/app/ifcRuntime-4RG2XHEC.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc43.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + } + ], + "errors": [], + "consoleErrors": [], + "inputLimit": true, + "abort": true +} \ No newline at end of file diff --git a/packages/renderers/3d/IFC.md b/packages/renderers/3d/IFC.md index e0e590066..f0a359df8 100644 --- a/packages/renderers/3d/IFC.md +++ b/packages/renderers/3d/IFC.md @@ -63,6 +63,58 @@ const ifc = createIfcRenderer({ }) ``` +### Pre-import settings and pre-model runtime hook + +This data-only bridge incorporates the advanced configuration direction proposed +by @p4535992 in PR #275, on the owned-Worker architecture from PR #276. It does not +add the draft's duplicate runtime or separate capability/assets packages. + +```ts +createIfcRenderer({ + thatOpen: { + importer: { + webIfcSettings: { COORDINATE_TO_ORIGIN: true, CIRCLE_SEGMENTS: 24 }, + geometryProcessSettings: { threshold: 3000 }, + includeMaterialProperties: true + }, + fragments: { settings: { maxUpdateRate: 80 } } + }, + configureRuntime({ components, fragments, world, signal }) { + // Actual adapter-owned objects, before fragments.load() creates the model. + // Configure Components/camera/scene here, not through private-field assignment. + const handler = () => { /* application-specific integration */ } + window.addEventListener('bim-settings', handler, { signal }) + return () => window.removeEventListener('bim-settings', handler) + }, + configure({ model }) { + // Existing post-load hook remains available. + } +}) +``` + +`thatOpen.importer` accepts existing public data fields on the installed +`IfcImporter`; `thatOpen.fragments.settings` accepts public writable fields on +`FragmentsModels.settings`. These are advanced, upstream-version-coupled APIs, +not a normalization of every That Open release. Omitted fields preserve defaults. +Nested Loader/geometry bags are merged; native Sets/Maps replace contents while +retaining library-owned collection instances. Use native `Set` for +`attributesToExclude` and native `Map` for `relations`. + +Settings are copied before Worker allocation or copying file bytes. Only plain +data, finite numbers, arrays and native Sets/Maps are accepted, limited to 2,048 +nodes, eight nesting levels and 65,536 cumulative string/key characters. Functions, +accessors, class instances, cycles, prototype/private keys and custom collection +properties are rejected. WASM locations, executable methods and Worker ownership +remain adapter-controlled. Unknown top-level fields fail rather than being ignored. +The Worker validates settings again before parsing. These shape/size guards do +not replace upstream documentation for valid option values; configuration is +trusted application code, never document-supplied executable metadata. + +Both hooks may be asynchronous and return synchronous cleanup. Cleanup runs once +in reverse registration order, including late completion after cancellation. A +failing cleanup does not prevent other hooks or Workers/WebGL from being disposed. +Never dispose adapter-owned objects or replace their Worker/lifecycle methods. + The adapter owns and disposes these objects. Do not dispose them in the hook. Return cleanup for your own resources. A late async hook is cleaned up after cancellation. The explicit `renderFileViewerIfc` API also returns `select(id|null)`, diff --git a/packages/renderers/3d/package.json b/packages/renderers/3d/package.json index e140e37b2..27b33e85c 100644 --- a/packages/renderers/3d/package.json +++ b/packages/renderers/3d/package.json @@ -67,7 +67,7 @@ "scripts": { "build": "tsc -b tsconfig.json", "type-check": "tsc -b tsconfig.json", - "verify:ifc": "node scripts/verify-ifc-entry.mjs", + "verify:ifc": "node scripts/verify-ifc-entry.mjs && node --test scripts/ifc-settings.test.mjs", "verify:ifc-browser": "node scripts/verify-ifc-browser.mjs" }, "dependencies": { diff --git a/packages/renderers/3d/scripts/ifc-settings.test.mjs b/packages/renderers/3d/scripts/ifc-settings.test.mjs new file mode 100644 index 000000000..268b5a15f --- /dev/null +++ b/packages/renderers/3d/scripts/ifc-settings.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { IfcImporter } from "@thatopen/fragments"; +import { + applyIfcSettings, + copyIfcImporterSettings, + copyIfcSettings, +} from "../dist/ifcSettings.js"; + +test("data settings are independent snapshots with native Sets/Maps", () => { + const source = { + webIfcSettings: { COORDINATE_TO_ORIGIN: false, CIRCLE_SEGMENTS: 24 }, + attributesToExclude: new Set(["Name"]), + relations: new Map([[1, { forRelating: "a", forRelated: "b" }]]), + }; + const copy = copyIfcImporterSettings(source); + source.attributesToExclude.add("GlobalId"); + source.webIfcSettings.CIRCLE_SEGMENTS = 32; + assert.deepEqual([...copy.attributesToExclude], ["Name"]); + assert.equal(copy.webIfcSettings.CIRCLE_SEGMENTS, 24); + assert.ok(copy.relations instanceof Map); + assert.deepEqual(copyIfcSettings(undefined), {}); +}); +test("accessors, functions, classes, cycles and oversized inputs fail closed", () => { + const cyclic = {}; + cyclic.self = cyclic; + let called = false; + const accessor = { + get webIfcSettings() { + called = true; + return {}; + }, + }; + for (const value of [ + accessor, + cyclic, + { fn() {} }, + { date: new Date() }, + { value: Infinity }, + { value: undefined }, + { text: "x".repeat(65537) }, + { values: new Set(Array.from({ length: 2049 }, (_, i) => i)) }, + { values: new Array(2049) }, + ]) + assert.throws(() => copyIfcSettings(value)); + assert.equal(called, false); + let depth = {}; + for (let i = 0; i < 10; i++) depth = { inner: depth }; + assert.throws(() => copyIfcSettings(depth), /limits/); +}); +test("prototype/private keys and resource/method overrides are rejected", () => { + for (const key of [ + "__proto__", + "constructor", + "prototype", + "_builder", + "wasm", + "webIfc", + "workerUrl", + "process", + "dispose", + ]) + assert.throws(() => copyIfcImporterSettings(JSON.parse(`{"${key}":{}}`))); + assert.throws(() => + copyIfcSettings( + JSON.parse('{"webIfcSettings":{"__proto__":{"polluted":true}}}'), + ), + ); + assert.equal({}.polluted, undefined); +}); +test("real importer overrides preserve library-owned collections and unrelated defaults", () => { + const importer = new IfcImporter(); + const excluded = importer.attributesToExclude, + classes = importer.classes.elements, + wasm = importer.wasm, + process = importer.process; + applyIfcSettings( + importer, + copyIfcImporterSettings({ + webIfcSettings: { COORDINATE_TO_ORIGIN: false, CIRCLE_SEGMENTS: 24 }, + geometryProcessSettings: { threshold: 1000 }, + attributesToExclude: new Set(["Name"]), + classes: { elements: new Set([123]) }, + includeUniqueAttributes: false, + includeMaterialProperties: true, + }), + ); + assert.equal(importer.webIfcSettings.COORDINATE_TO_ORIGIN, false); + assert.equal(importer.webIfcSettings.CIRCLE_SEGMENTS, 24); + assert.equal(importer.geometryProcessSettings.threshold, 1000); + assert.equal(importer.geometryProcessSettings.precision, 1e6); + assert.equal(importer.attributesToExclude, excluded); + assert.deepEqual([...excluded], ["Name"]); + assert.equal(importer.classes.elements, classes); + assert.deepEqual([...classes], [123]); + assert.equal(importer.wasm, wasm); + assert.equal(importer.process, process); + assert.equal(importer.includeMaterialProperties, true); + assert.throws( + () => applyIfcSettings(importer, { typoOption: true }), + /Unknown/, + ); + assert.throws( + () => applyIfcSettings(importer, { includeUniqueAttributes: "false" }), + /Incompatible/, + ); + assert.throws( + () => applyIfcSettings(importer, { attributesToExclude: ["Name"] }), + /requires a Set/, + ); +}); +test("Fragments configuration only assigns own writable data fields", () => { + const settings = { maxUpdateRate: 100, graphicsQuality: 0 }; + applyIfcSettings(settings, copyIfcSettings({ maxUpdateRate: 80 })); + assert.equal(settings.maxUpdateRate, 80); + assert.throws( + () => applyIfcSettings(settings, { dispose: false }), + /Unknown/, + ); + let called = false; + Object.defineProperty(settings, "accessor", { + get() { + called = true; + }, + }); + assert.throws(() => applyIfcSettings(settings, { accessor: 1 }), /non-data/); + assert.equal(called, false); +}); +test("native collection accessors are rejected without executing application code", () => { + let called = false; + const collection = new Set([1]); + Object.defineProperty(collection, "size", { + get() { + called = true; + return 1; + }, + }); + assert.throws(() => copyIfcSettings({ collection }), /custom properties/); + assert.equal(called, false); +}); diff --git a/packages/renderers/3d/scripts/verify-ifc-browser.mjs b/packages/renderers/3d/scripts/verify-ifc-browser.mjs index 8f06d69b4..2341ef470 100644 --- a/packages/renderers/3d/scripts/verify-ifc-browser.mjs +++ b/packages/renderers/3d/scripts/verify-ifc-browser.mjs @@ -48,8 +48,10 @@ const built = await build({ minify: true, logLevel: "silent", }); -const entry = Object.entries(built.metafile.outputs).find(([, value]) => - value.entryPoint && resolve(value.entryPoint) === resolve(here, "../dist/ifc.js"), +const entry = Object.entries(built.metafile.outputs).find( + ([, value]) => + value.entryPoint && + resolve(value.entryPoint) === resolve(here, "../dist/ifc.js"), ); assert.ok(entry); assert.ok(entry[1].bytes < 10_000, `Entry not lazy: ${entry[1].bytes}`); @@ -59,7 +61,7 @@ window.openIfc = async (name, options={}) => { window.controller=new AbortController(); window.selections=[]; window.cleanupCount=0; const bytes=await (await fetch('/sample/'+name)).arrayBuffer(); window.instance=await renderFileViewerIfc(bytes,document.getElementById('viewer'),{signal:controller.signal,options:{locale:'en-US'}},{assetBaseUrl:'/assets/',...options, - onSelectionChange:value=>window.selections.push(value),configure:context=>{window.extension=context;return()=>{window.cleanupCount++}}}); + onSelectionChange:value=>window.selections.push(value),configure:async context=>{window.extension=context;const cleanup=await options.configure?.(context);return()=>{try{cleanup?.()}finally{window.cleanupCount++}}}}); return {count:Number(instance.$el.dataset.ifcElementCount),first:Number(instance.$el.dataset.ifcFirstElement)}; }; window.entryReady=true; `; @@ -128,7 +130,10 @@ try { ); await page.addInitScript(() => { // Intranet HTTP hosts may not expose this secure-context-only API. - Object.defineProperty(window.crypto, "randomUUID", { value: undefined, configurable: true }); + Object.defineProperty(window.crypto, "randomUUID", { + value: undefined, + configurable: true, + }); const Original = window.Worker; window.workerCounts = { created: 0, active: 0 }; window.Worker = class extends Original { @@ -149,7 +154,10 @@ try { }); await page.goto(origin); await page.waitForFunction(() => window.entryReady); - assert.equal(await page.evaluate(() => typeof crypto.randomUUID), "undefined"); + assert.equal( + await page.evaluate(() => typeof crypto.randomUUID), + "undefined", + ); assert.equal(await page.evaluate(() => workerCounts.created), 0); assert.ok( !requests.some( @@ -210,7 +218,9 @@ try { String(id), hit.localId, ); - await page.waitForFunction(() => extension.world.renderer.three.info.render.triangles > 0); + await page.waitForFunction( + () => extension.world.renderer.three.info.render.triangles > 0, + ); const drawn = await page.evaluate(() => ({ calls: extension.world.renderer.three.info.render.calls, triangles: extension.world.renderer.three.info.render.triangles, @@ -236,6 +246,164 @@ try { }); console.log("Passed", name, JSON.stringify(report.cases.at(-1))); } + // Prove non-default importer settings cross the real Worker boundary. + const advanced = await page.evaluate(async () => { + window.hookOrder = []; + let runtimeHadModels; + const loaded = await openIfc("ifc4.ifc", { + thatOpen: { + importer: { + attributesToExclude: new Set([ + "Representation", + "ObjectPlacement", + "CompositionType", + "OwnerHistory", + "Name", + ]), + webIfcSettings: { COORDINATE_TO_ORIGIN: true, CIRCLE_SEGMENTS: 24 }, + }, + fragments: { settings: { maxUpdateRate: 80 } }, + }, + configureRuntime({ fragments, signal }) { + runtimeHadModels = fragments.models.list.size; + if (signal.aborted) throw new Error("Unexpected aborted runtime hook"); + return () => hookOrder.push("runtime"); + }, + configure() { + return () => hookOrder.push("model"); + }, + }); + const selected = await instance.select(loaded.first), + rate = extension.fragments.settings.maxUpdateRate; + await instance.unmount(); + await instance.unmount(); + return { + count: loaded.count, + name: selected.name, + rate, + runtimeHadModels, + order: hookOrder, + cleanupCount, + }; + }); + assert.equal(advanced.count, 13); + assert.equal( + advanced.name, + "", + "Importer exclusion did not affect the real parsed model", + ); + assert.equal(advanced.rate, 80); + assert.equal(advanced.runtimeHadModels, 0); + assert.deepEqual(advanced.order, ["model", "runtime"]); + assert.equal(advanced.cleanupCount, 1); + await page.waitForFunction(() => workerCounts.active === 0); + report.advanced = advanced; + const invalidSettings = await page.evaluate(async () => { + const created = workerCounts.created; + for (const importer of [ + { wasm: { path: "https://invalid.invalid/" } }, + { callback() {} }, + JSON.parse('{"__proto__":{}}'), + ]) { + try { + await openIfc("ifc4.ifc", { thatOpen: { importer } }); + return false; + } catch {} + if (workerCounts.created !== created) return false; + } + return true; + }); + assert.ok( + invalidSettings, + "Invalid settings must fail before Worker allocation", + ); + const unknown = await page.evaluate(async () => { + try { + await openIfc("ifc4.ifc", { + thatOpen: { importer: { typoOption: true } }, + }); + return false; + } catch (error) { + return /Unknown or non-data/.test(error.message); + } + }); + assert.ok(unknown); + await page.waitForFunction(() => workerCounts.active === 0); + const late = await page.evaluate(async () => { + window.runtimeEntered = false; + window.lateCleanup = 0; + const pending = openIfc("ifc4.ifc", { + configureRuntime() { + runtimeEntered = true; + return new Promise((resolve) => { + window.finishRuntime = resolve; + }); + }, + }).then( + () => false, + (error) => error.name === "AbortError", + ); + const deadline = Date.now() + 20000; + while (!runtimeEntered) { + if (Date.now() > deadline) + throw new Error("Runtime hook was not reached"); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + controller.abort(); + const aborted = await pending; + finishRuntime(() => { + lateCleanup++; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + return { aborted, cleanups: lateCleanup }; + }); + assert.deepEqual(late, { aborted: true, cleanups: 1 }); + await page.waitForFunction(() => workerCounts.active === 0); + assert.equal(await page.locator("canvas").count(), 0); + report.invalidSettings = invalidSettings; + report.unknownSettings = unknown; + report.lateRuntimeCleanup = late; + const badFragments = await page.evaluate(async () => { + try { + await openIfc("ifc4.ifc", { + thatOpen: { fragments: { settings: { typoOption: true } } }, + }); + return false; + } catch (error) { + return /Unknown or non-data/.test(error.message); + } + }); + assert.ok(badFragments); + await page.waitForFunction(() => workerCounts.active === 0); + const throwingCleanup = await page.evaluate(async () => { + window.cleanupOrder = []; + await openIfc("ifc4.ifc", { + configureRuntime() { + return () => cleanupOrder.push("runtime"); + }, + configure() { + return () => { + cleanupOrder.push("model"); + throw new Error("Expected host cleanup failure"); + }; + }, + }); + let rejected = false; + try { + await instance.unmount(); + } catch (error) { + rejected = error instanceof AggregateError; + } + return { rejected, order: cleanupOrder }; + }); + assert.deepEqual(throwingCleanup, { + rejected: true, + order: ["model", "runtime"], + }); + await page.waitForFunction(() => workerCounts.active === 0); + assert.equal(await page.locator("canvas").count(), 0); + report.badFragments = badFragments; + report.throwingCleanup = throwingCleanup; // Input limit is enforced before another worker or transferable copy is allocated. const limit = await page.evaluate(async () => { const n = workerCounts.created; diff --git a/packages/renderers/3d/src/ifc-import.worker.ts b/packages/renderers/3d/src/ifc-import.worker.ts index 2d00e2084..aa687fc27 100644 --- a/packages/renderers/3d/src/ifc-import.worker.ts +++ b/packages/renderers/3d/src/ifc-import.worker.ts @@ -1,9 +1,16 @@ +import { applyIfcSettings, copyIfcImporterSettings } from "./ifcSettings.js"; import { IfcImporter } from "@thatopen/fragments"; // Compiled into a self-hosted module Worker by the optional asset installer. const scope = globalThis as unknown as { onmessage: - | ((event: MessageEvent<{ bytes: ArrayBuffer; wasmPath: string }>) => void) + | (( + event: MessageEvent<{ + bytes: ArrayBuffer; + wasmPath: string; + importerSettings?: Record; + }>, + ) => void) | null; postMessage(message: unknown, transfer?: Transferable[]): void; }; @@ -17,6 +24,7 @@ scope.onmessage = async ({ data }) => { importer.webIfcSettings = { COORDINATE_TO_ORIGIN: true }; importer.includeUniqueAttributes = true; importer.includeRelationNames = true; + applyIfcSettings(importer, copyIfcImporterSettings(data.importerSettings)); const result = await importer.process({ bytes: new Uint8Array(data.bytes), progressCallback: (progress) => diff --git a/packages/renderers/3d/src/ifc.ts b/packages/renderers/3d/src/ifc.ts index 7abf16606..96cc8d86a 100644 --- a/packages/renderers/3d/src/ifc.ts +++ b/packages/renderers/3d/src/ifc.ts @@ -22,7 +22,24 @@ export interface IfcExtensionContext { signal: AbortSignal; select: (localId: number | null) => Promise; } +/** Advanced runtime objects are adapter-owned; return cleanup only for host resources. */ +export type IfcRuntimeContext = Pick< + IfcExtensionContext, + "components" | "fragments" | "world" | "signal" +>; +export interface IfcThatOpenOptions { + /** Public IfcImporter data fields; no executable methods, WASM or Worker overrides. */ + importer?: Readonly>; + /** Public FragmentsModels.settings fields, not constructor/Worker ownership. */ + fragments?: { settings?: Readonly> }; +} export interface IfcViewerOptions { + /** Optional pre-import data settings; defaults are unchanged when omitted. */ + thatOpen?: IfcThatOpenOptions; + /** Runs after runtime creation, before model loading. Return host-resource cleanup. */ + configureRuntime?: ( + context: IfcRuntimeContext, + ) => void | (() => void) | Promise void)>; /** Directory installed by file-viewer-ifc-assets. Defaults to /file-viewer/vendor/ifc/. */ assetBaseUrl?: string | URL; fitToModel?: boolean; diff --git a/packages/renderers/3d/src/ifcRuntime.ts b/packages/renderers/3d/src/ifcRuntime.ts index b94a0ae68..bef908887 100644 --- a/packages/renderers/3d/src/ifcRuntime.ts +++ b/packages/renderers/3d/src/ifcRuntime.ts @@ -1,3 +1,8 @@ +import { + applyIfcSettings, + copyIfcImporterSettings, + copyIfcSettings, +} from "./ifcSettings.js"; import * as THREE from "three"; import * as OBC from "@thatopen/components"; import * as FRAGS from "@thatopen/fragments"; @@ -41,6 +46,11 @@ export async function renderIfc( throw new Error("IFC input exceeds the configured size limit"); if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw new Error("Invalid IFC load timeout"); + const importerSettings = copyIfcImporterSettings(options.thatOpen?.importer); + const fragmentsSettings = copyIfcSettings( + options.thatOpen?.fragments?.settings, + "IFC Fragments settings", + ); const header = new TextDecoder().decode(buffer.slice(0, 65536)); if ( !/^\s*ISO-10303-21\s*;/i.test(header.replace(/^\uFEFF/, "")) || @@ -131,7 +141,7 @@ export async function renderIfc( | OBC.SimpleWorld | undefined; let model: FRAGS.FragmentsModel | undefined; - let extensionCleanup: void | (() => void); + const extensionCleanups: Array<() => void> = []; let ready = false; let selectionId = 0; let selectionQueue: Promise = Promise.resolve(); @@ -179,19 +189,30 @@ export async function renderIfc( components = undefined; const currentFragments = fragments; fragments = undefined; - const cleanup = extensionCleanup; - extensionCleanup = undefined; + const cleanups = extensionCleanups.splice(0).reverse(); root.remove(); style.remove(); disposePromise = (async () => { try { - cleanup?.(); + const errors: unknown[] = []; + for (const cleanup of cleanups) { + try { + cleanup(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) + throw new AggregateError(errors, "IFC extension cleanup failed"); } finally { // Stop the frame loop before releasing worker-owned model geometry. if (currentComponents) currentComponents.enabled = false; try { if (currentFragments) { - currentFragments.abort(id); + // abort() routes through the upstream connection and creates a Worker + // for an unknown model ID. Before load(), there is nothing to abort. + if (currentFragments.models.list.has(id)) + currentFragments.abort(id); await currentFragments.dispose(); } } finally { @@ -324,6 +345,26 @@ export async function renderIfc( selectionQueue = next; return next; }; + const configureExtension = async ( + hook: () => void | (() => void) | Promise void)>, + ) => { + ensureLive(); + const pending = Promise.resolve() + .then(() => { + ensureLive(); + return hook(); + }) + .then((cleanup) => { + if (cleanup !== undefined && typeof cleanup !== "function") + throw new TypeError( + "IFC extension hook must return a cleanup function or undefined", + ); + if (disposed) cleanup?.(); + else if (cleanup) extensionCleanups.push(cleanup); + }); + await withCancellation(pending); + ensureLive(); + }; fitButton.addEventListener("click", () => { void fitToModel().catch(showError); }); @@ -358,9 +399,10 @@ export async function renderIfc( importWorker.onmessageerror = () => reject(new Error("Invalid IFC worker response")); const copy = buffer.slice(0); - importWorker.postMessage({ bytes: copy, wasmPath: assetBase.href }, [ - copy, - ]); + importWorker.postMessage( + { bytes: copy, wasmPath: assetBase.href, importerSettings }, + [copy], + ); }); timeout = setTimeout(() => { controller.abort(new Error("IFC loading timed out")); @@ -390,6 +432,17 @@ export async function renderIfc( new URL("fragments.worker.mjs", assetBase).href, { maxWorkers: 2 }, ); + applyIfcSettings(fragments.settings, fragmentsSettings); + if (options.configureRuntime) { + await configureExtension(() => + options.configureRuntime!({ + components: components!, + fragments: fragments!, + world: world!, + signal: controller.signal, + }), + ); + } model = await withCancellation(fragments.load(bytes, { modelId: id })); ensureLive(); model.useCamera(world.camera.three); @@ -442,24 +495,16 @@ export async function renderIfc( canvas.removeEventListener("pointerup", up); }; if (options.configure) { - const pending = Promise.resolve( - options.configure({ - components, - fragments, - world, - model, + await configureExtension(() => + options.configure!({ + components: components!, + fragments: fragments!, + world: world!, + model: model!, signal: controller.signal, select, }), ); - void pending - .then((cleanup) => { - if (disposed) cleanup?.(); - else extensionCleanup = cleanup; - }) - .catch(showError); - await withCancellation(pending); - ensureLive(); } clearTimeout(timeout); root.dataset.ifcStatus = "ready"; diff --git a/packages/renderers/3d/src/ifcSettings.ts b/packages/renderers/3d/src/ifcSettings.ts new file mode 100644 index 000000000..89422cc7f --- /dev/null +++ b/packages/renderers/3d/src/ifcSettings.ts @@ -0,0 +1,185 @@ +/** Bounded data-only bridge. This module does not import the optional BIM engines. */ +export type IfcSettings = Readonly>; +const unsafe = (key: string) => + key.startsWith("_") || ["constructor", "prototype"].includes(key); +const isRecord = (value: unknown): value is Record => + value !== null && + typeof value === "object" && + [Object.prototype, null].includes(Object.getPrototypeOf(value)); + +/** Copy before allocating a Worker or transferring input. No accessors are executed. */ +export function copyIfcSettings( + value: unknown, + label = "IFC settings", +): Record { + if (value === undefined) return {}; + if (!isRecord(value)) throw new TypeError(`${label} must be a plain record`); + let nodes = 0, + characters = 0; + const active = new Set(); + const copy = (input: unknown, depth: number): unknown => { + if (++nodes > 2048 || depth > 8) + throw new RangeError(`${label} exceeds configuration limits`); + if (typeof input === "string") { + characters += input.length; + if (characters > 65536) + throw new RangeError(`${label} exceeds configuration limits`); + return input; + } + if (input === null || typeof input === "boolean") return input; + if (typeof input === "number" && Number.isFinite(input)) return input; + if (!input || typeof input !== "object") + throw new TypeError( + `${label} accepts data, not functions or undefined values`, + ); + if (active.has(input)) + throw new TypeError(`${label} must not contain cycles`); + active.add(input); + try { + if (Object.getPrototypeOf(input) === Set.prototype) { + if (Reflect.ownKeys(input).length) + throw new TypeError( + `${label} collections must not have custom properties`, + ); + if ((input as Set).size > 2048) + throw new RangeError(`${label} exceeds configuration limits`); + return new Set( + [...Set.prototype.values.call(input)].map((v) => copy(v, depth + 1)), + ); + } + if (Object.getPrototypeOf(input) === Map.prototype) { + if (Reflect.ownKeys(input).length) + throw new TypeError( + `${label} collections must not have custom properties`, + ); + if ((input as Map).size > 2048) + throw new RangeError(`${label} exceeds configuration limits`); + return new Map( + [...Map.prototype.entries.call(input)].map(([k, v]) => { + if (typeof k !== "string" && typeof k !== "number") + throw new TypeError( + `${label} Map keys must be strings or numbers`, + ); + if (typeof k === "string" && unsafe(k)) + throw new TypeError(`${label} contains a reserved key`); + return [copy(k, depth + 1), copy(v, depth + 1)]; + }), + ); + } + const array = + Array.isArray(input) && + Object.getPrototypeOf(input) === Array.prototype; + if (!array && !isRecord(input)) + throw new TypeError( + `${label} accepts only plain data, arrays, Sets and Maps`, + ); + if (array && (input as unknown[]).length > 2048) + throw new RangeError(`${label} exceeds configuration limits`); + const output: Record | unknown[] = array ? [] : {}; + for (const key of Reflect.ownKeys(input)) { + if (array && key === "length") continue; + if (typeof key !== "string" || unsafe(key)) + throw new TypeError(`${label} contains a reserved key`); + const descriptor = Object.getOwnPropertyDescriptor(input, key)!; + if (!("value" in descriptor)) + throw new TypeError(`${label} must not contain accessors`); + if (!descriptor.enumerable) + throw new TypeError(`${label} must contain enumerable data only`); + if (array && !/^(0|[1-9]\d*)$/.test(key)) + throw new TypeError(`${label} contains an invalid array key`); + characters += key.length; + if (characters > 65536) + throw new RangeError(`${label} exceeds configuration limits`); + Object.defineProperty(output, key, { + value: copy(descriptor.value, depth + 1), + enumerable: true, + writable: true, + configurable: true, + }); + } + if (array && (input as unknown[]).length !== (output as unknown[]).length) + throw new TypeError(`${label} must not contain sparse trailing arrays`); + return output; + } finally { + active.delete(input); + } + }; + return copy(value, 0) as Record; +} + +export function copyIfcImporterSettings( + value: unknown, +): Record { + const result = copyIfcSettings(value, "IFC importer settings"); + for (const key of Object.keys(result)) { + if ( + ["wasm", "webIfc", "worker", "workerUrl", "process", "dispose"].includes( + key, + ) + ) + throw new TypeError(`IFC importer setting is adapter-owned: ${key}`); + } + return result; +} + +/** Apply only existing public data fields, retaining library-owned Set/Map instances. */ +export function applyIfcSettings(target: object, settings: IfcSettings): void { + const merge = (old: unknown, next: unknown, path: string): unknown => { + if (old instanceof Set) { + if (!(next instanceof Set)) throw new TypeError(`${path} requires a Set`); + old.clear(); + for (const value of next) old.add(value); + return old; + } + if (old instanceof Map) { + if (!(next instanceof Map)) throw new TypeError(`${path} requires a Map`); + old.clear(); + for (const [key, value] of next) old.set(key, value); + return old; + } + if (isRecord(old) && isRecord(next)) { + // Loader/geometry bags accept new upstream fields; collections stay library-owned. + for (const [key, value] of Object.entries(next)) { + if (unsafe(key)) + throw new TypeError(`Reserved IFC setting: ${path}.${key}`); + const descriptor = Object.getOwnPropertyDescriptor(old, key); + if ( + descriptor && + (!("value" in descriptor) || typeof descriptor.value === "function") + ) + throw new TypeError(`Executable IFC setting: ${path}.${key}`); + Object.defineProperty(old, key, { + value: descriptor + ? merge(descriptor.value, value, `${path}.${key}`) + : value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return old; + } + if ( + old !== null && + (typeof old !== typeof next || typeof old === "function") + ) + throw new TypeError(`Incompatible IFC setting: ${path}`); + return next; + }; + for (const [key, value] of Object.entries(settings)) { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if ( + unsafe(key) || + !descriptor || + !("value" in descriptor) || + !descriptor.writable || + typeof descriptor.value === "function" + ) + throw new TypeError(`Unknown or non-data IFC setting: ${key}`); + (target as Record)[key] = merge( + descriptor.value, + value, + key, + ); + } +} From 0b84e9d5dec5344b990511558c07013d2e1e98e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:29:47 +0000 Subject: [PATCH 3/4] fix(ifc): share one teardown promise across reentrant callbacks Proves failure before repair and passes real-browser abort-listener and extension-cleanup reentrancy with zero Workers on resolution. No publication. --- .../issue-267/advanced/report.json | 126 +++++++++++------- .../3d/scripts/verify-ifc-browser.mjs | 36 +++++ packages/renderers/3d/src/ifcRuntime.ts | 47 ++++--- 3 files changed, 142 insertions(+), 67 deletions(-) diff --git a/docs/regressions/issue-267/advanced/report.json b/docs/regressions/issue-267/advanced/report.json index ea3ba936a..86a49cf13 100644 --- a/docs/regressions/issue-267/advanced/report.json +++ b/docs/regressions/issue-267/advanced/report.json @@ -16,7 +16,7 @@ "calls": 7, "triangles": 1143 }, - "milliseconds": 1448, + "milliseconds": 1451, "workers": { "created": 2, "active": 0 @@ -34,7 +34,7 @@ }, "picked": 201, "drawn": { - "calls": 8, + "calls": 7, "triangles": 1143 }, "milliseconds": 930, @@ -69,190 +69,220 @@ "runtime" ] }, + "reentrant": { + "abortSame": true, + "cleanupSame": true, + "activeWorkers": 0, + "cleanupCount": 1 + }, "requests": [ { "method": "GET", - "url": "http://127.0.0.1:45013/" + "url": "http://127.0.0.1:38625/" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/app/ifc.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/app/chunk-WOT6VMZA.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/app/ifcRuntime-EKX5OYQW.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" + }, + { + "method": "GET", + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/app/ifc.js" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/app/chunk-WOT6VMZA.js" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/fragments.worker.mjs" }, { "method": "GET", - "url": "http://127.0.0.1:45013/app/ifcRuntime-4RG2XHEC.js" + "url": "http://127.0.0.1:38625/sample/ifc43.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + "url": "http://127.0.0.1:38625/assets/fragments.worker.mjs" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc43.ifc" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + "url": "http://127.0.0.1:38625/assets/fragments.worker.mjs" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/fragments.worker.mjs" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/web-ifc.wasm" + "url": "http://127.0.0.1:38625/assets/web-ifc.wasm" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/fragments.worker.mjs" + "url": "http://127.0.0.1:38625/assets/fragments.worker.mjs" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/sample/ifc4.ifc" + "url": "http://127.0.0.1:38625/sample/ifc4.ifc" }, { "method": "GET", - "url": "http://127.0.0.1:45013/assets/ifc-import.worker.js" + "url": "http://127.0.0.1:38625/assets/ifc-import.worker.js" } ], "errors": [], diff --git a/packages/renderers/3d/scripts/verify-ifc-browser.mjs b/packages/renderers/3d/scripts/verify-ifc-browser.mjs index 2341ef470..f20b64e3e 100644 --- a/packages/renderers/3d/scripts/verify-ifc-browser.mjs +++ b/packages/renderers/3d/scripts/verify-ifc-browser.mjs @@ -404,6 +404,42 @@ try { assert.equal(await page.locator("canvas").count(), 0); report.badFragments = badFragments; report.throwingCleanup = throwingCleanup; + const reentrant = await page.evaluate(async () => { + window.abortDisposal = null; + window.cleanupDisposal = null; + await openIfc("ifc4.ifc", { + configureRuntime({ signal }) { + signal.addEventListener( + "abort", + () => { + window.abortDisposal = instance.unmount(); + }, + { once: true }, + ); + return () => { + window.cleanupDisposal = instance.unmount(); + }; + }, + }); + const pending = instance.unmount(); + await pending; + const result = { + abortSame: abortDisposal === pending, + cleanupSame: cleanupDisposal === pending, + activeWorkers: workerCounts.active, + cleanupCount, + }; + // Drain any wrongly detached cleanup promises before asserting the result. + await Promise.allSettled([abortDisposal, cleanupDisposal]); + return result; + }); + assert.deepEqual( + reentrant, + { abortSame: true, cleanupSame: true, activeWorkers: 0, cleanupCount: 1 }, + "Reentrant teardown must share one settled cleanup promise", + ); + assert.equal(await page.locator("canvas").count(), 0); + report.reentrant = reentrant; // Input limit is enforced before another worker or transferable copy is allocated. const limit = await page.evaluate(async () => { const n = workerCounts.created; diff --git a/packages/renderers/3d/src/ifcRuntime.ts b/packages/renderers/3d/src/ifcRuntime.ts index bef908887..5b0ce2f0f 100644 --- a/packages/renderers/3d/src/ifcRuntime.ts +++ b/packages/renderers/3d/src/ifcRuntime.ts @@ -175,24 +175,33 @@ export async function renderIfc( }; const unmount = (): Promise => { if (disposePromise) return disposePromise; - disposed = true; - ready = false; - selectionId++; - if (!controller.signal.aborted) controller.abort(abortError()); - clearTimeout(timeout); - importWorker?.terminate(); - importWorker = undefined; - context?.signal?.removeEventListener("abort", onAbort); - removeCanvasEvents(); - removeControlEvents(); - const currentComponents = components; - components = undefined; - const currentFragments = fragments; - fragments = undefined; - const cleanups = extensionCleanups.splice(0).reverse(); - root.remove(); - style.remove(); - disposePromise = (async () => { + // Assign before abort/cleanup callbacks can reenter unmount(). Every caller + // must await the same complete resource teardown, not an early promise. + let finish!: () => void; + let fail!: (error: unknown) => void; + disposePromise = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + void (async () => { + disposed = true; + ready = false; + selectionId++; + if (!controller.signal.aborted) controller.abort(abortError()); + clearTimeout(timeout); + importWorker?.terminate(); + importWorker = undefined; + context?.signal?.removeEventListener("abort", onAbort); + removeCanvasEvents(); + removeControlEvents(); + const currentComponents = components; + components = undefined; + const currentFragments = fragments; + fragments = undefined; + const cleanups = extensionCleanups.splice(0).reverse(); + root.remove(); + style.remove(); + try { const errors: unknown[] = []; for (const cleanup of cleanups) { @@ -219,7 +228,7 @@ export async function renderIfc( currentComponents?.dispose(); } } - })(); + })().then(finish, fail); return disposePromise; }; const onAbort = () => { From 5a37e3b2132cd1afdfda8a9f9605cc51f44ade87 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:30:41 -0700 Subject: [PATCH 4/4] docs(ifc): record advanced settings and reentrant teardown qualification --- docs/regressions/issue-267/advanced/README.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/regressions/issue-267/advanced/README.md diff --git a/docs/regressions/issue-267/advanced/README.md b/docs/regressions/issue-267/advanced/README.md new file mode 100644 index 000000000..82f73ee0f --- /dev/null +++ b/docs/regressions/issue-267/advanced/README.md @@ -0,0 +1,58 @@ +# Advanced IFC regression evidence + +This directory records the real-browser qualification of PR #278's combined +advanced configuration and lifecycle work. Source fixes are in +`0b84e9d5dec5344b990511558c07013d2e1e98e0`; this evidence note does not change runtime +or dependency bytes. No package was published by these qualifications. + +## Qualified scenarios + +- Official, checksum-pinned IFC4 and IFC4.3 Building-Architecture samples still + render 13 geometric items and 1,143 triangles in the tested scene. +- Data-only importer settings cross the real Worker boundary: excluding `Name` + changes the actual selected model data rather than merely a configuration mock. +- Fragments settings are applied to the real instance. The pre-model hook runs + before any model is registered; the existing post-model hook is preserved. +- Invalid importer settings fail before file copying/Worker allocation. Unknown + importer and Fragments fields fail explicitly and clean up their resources. +- A late asynchronous runtime hook cleans up once after cancellation. Cleanup + functions execute in reverse registration order, and a throwing cleanup does + not prevent remaining hooks, Workers or WebGL resources from being released. +- Reentrant `unmount()` calls from both an abort listener and a cleanup callback + return the exact same Promise. Once it resolves, zero tracked Workers remain. + +## Failure-before-fix evidence + +The initial advanced integration run `34680017521` caught an orphan Worker on +pre-model cancellation. Upstream `abort(id)` creates a connection for an unknown +model ID, so the adapter now calls it only for an already registered model. The +corrected integration run `34680261413` passed without weakening the assertions. + +The reentrant teardown qualification `34680829005` first ran the new regression +against source `5fbbf5bb575c94fc7c15ce4698ae9f5ebfc46026` and required failure on the +promise-identity assertion. It then assigned the shared disposal Promise before +notifying abort/cleanup callbacks, rebuilt the actual renderer, and passed the +same real-browser assertions plus all preceding advanced/original-model checks. +The corrected source was pushed only after those checks and governance passed. + +`report.json` is the resulting browser measurement record. Run IDs identify the +GitHub Actions evidence; the initial failed runs are not counted as passing gates. +The current PR's full CI and Security remain separate required merge gates. + +## Reproduce + +```sh +pnpm install --frozen-lockfile +pnpm --filter @file-viewer/core build +pnpm --filter @file-viewer/geometry-engine build +pnpm --filter @file-viewer/renderer-3d build +pnpm --filter @file-viewer/renderer-3d verify:ifc +pnpm exec playwright install chromium +node packages/renderers/3d/scripts/download-ifc-fixtures.mjs /tmp/ifc-samples +pnpm --filter @file-viewer/renderer-3d verify:ifc-browser /tmp/ifc-samples +``` + +Fixture provenance and CC BY 4.0 attribution are in the downloader and +`packages/renderers/3d/IFC.md`. The tests do not claim arbitrary-model fidelity, +full BIM authoring, or performance on every device. Advanced settings remain +upstream-version-coupled application configuration, not executable document data.