From 161d81379a8de7e2edf17a87e3f24eb0f3dc063f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:58:43 +0000 Subject: [PATCH 1/5] fix(ofd): distinguish digital signatures and qualify upstream release integration Refs #266. Preserve existing SES appearance, never claim cryptographic verification, and gate the next release on actual installed DOCX behavior. --- .github/scripts/prepare-docx-upstream.mjs | 130 ++++++++++++++++++ .../scripts/prepare-docx-upstream.test.mjs | 41 ++++++ .github/scripts/verify-docx-upstream.mjs | 85 ++++++++++++ .../scripts/verify-issue-266-samples.mjs | 7 +- .../maintenance/release-readiness-20260911.md | 40 ++++++ package.json | 8 +- .../scripts/verify-signature-containers.mjs | 80 +++++++++++ .../ofd/vendor/dltech/ofd/ofd_parser.js | 1 + .../vendor/dltech/ofd/ses_signature_parser.js | 46 ++++++- 9 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/prepare-docx-upstream.mjs create mode 100644 .github/scripts/prepare-docx-upstream.test.mjs create mode 100644 .github/scripts/verify-docx-upstream.mjs create mode 100644 docs/maintenance/release-readiness-20260911.md create mode 100644 packages/renderers/ofd/scripts/verify-signature-containers.mjs diff --git a/.github/scripts/prepare-docx-upstream.mjs b/.github/scripts/prepare-docx-upstream.mjs new file mode 100644 index 000000000..b34f3276b --- /dev/null +++ b/.github/scripts/prepare-docx-upstream.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict' +import { readFile, writeFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const manifestPath = 'packages/renderers/word/package.json' +const factsPaths = [ + 'apps/official-site/public/llms.txt', + 'apps/official-site/public/llms-full.txt', + 'docs/guide/faq.md', + 'docs/zh/guide/faq.md' +] +export function prepareDocxTexts(texts, version) { + assert.match( + version, + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, + 'Pass an exact published stable DOCX version, not a range, URL or tag' + ) + const manifest = JSON.parse(texts[manifestPath]) + const previous = manifest.dependencies['@file-viewer/docx'] + assert.match(previous, /^\d+\.\d+\.\d+$/) + const result = { ...texts } + manifest.dependencies['@file-viewer/docx'] = version + result[manifestPath] = JSON.stringify(manifest, null, 2) + '\n' + const path = 'packages/core/src/platform/assets.ts' + const before = `DEFAULT_FILE_VIEWER_DOCX_RUNTIME_VERSION = '${previous}'` + assert.ok(texts[path].includes(before), 'Core Worker provenance differs from the Word package') + result[path] = texts[path].replace( + before, + `DEFAULT_FILE_VIEWER_DOCX_RUNTIME_VERSION = '${version}'` + ) + result['pnpm-workspace.yaml'] = texts['pnpm-workspace.yaml'].replace( + `'@file-viewer/docx@${previous}'`, + `'@file-viewer/docx@${version}'` + ) + for (const path of factsPaths) { + result[path] = texts[path] + .replaceAll(`@file-viewer/docx@${previous}`, `@file-viewer/docx@${version}`) + .replaceAll(`file-viewer-docx=${previous}`, `file-viewer-docx=${version}`) + .replaceAll(`@file-viewer/docx\` ${previous}`, `@file-viewer/docx\` ${version}`) + assert.notEqual(result[path], texts[path], `Missing current DOCX version reference in ${path}`) + } + return { previous, result } +} +function run(command, args, capture = false) { + const executable = + process.platform === 'win32' && ['npm', 'pnpm'].includes(command) ? command + '.cmd' : command + const result = spawnSync(executable, args, { + cwd: root, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + timeout: 600_000, + shell: false + }) + if (result.error) throw result.error + assert.equal( + result.status, + 0, + `${command} failed: ${result.stderr || result.signal || result.status}` + ) + return result.stdout +} +async function main() { + const version = process.argv[2] + assert.equal(process.argv.length, 3, 'Usage: pnpm release:prepare-docx ') + assert.match(version || '', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/) + const paths = [ + manifestPath, + 'packages/core/src/platform/assets.ts', + 'pnpm-workspace.yaml', + ...factsPaths, + 'pnpm-lock.yaml' + ] + assert.equal( + run('git', ['status', '--porcelain', '--', ...paths], true).trim(), + '', + 'Commit or stash release metadata edits before updating DOCX' + ) + // Registry presence is checked before touching source files. This never publishes. + const actual = JSON.parse( + run( + 'npm', + [ + 'view', + `@file-viewer/docx@${version}`, + 'version', + '--json', + '--registry=https://registry.npmjs.org/' + ], + true + ) + ) + assert.equal(actual, version, 'The requested upstream version is not published') + const texts = Object.fromEntries( + await Promise.all( + paths.map(async (path) => [path, await readFile(resolve(root, path), 'utf8')]) + ) + ) + const current = JSON.parse(texts[manifestPath]).dependencies['@file-viewer/docx'] + if (current === version) { + run(process.execPath, ['.github/scripts/verify-docx-upstream.mjs']) + return + } + const { previous, result } = prepareDocxTexts(texts, version) + try { + await Promise.all( + Object.entries(result) + .filter(([path]) => path !== 'pnpm-lock.yaml') + .map(([path, text]) => writeFile(resolve(root, path), text)) + ) + run('pnpm', ['install', '--lockfile-only', '--ignore-scripts']) + run('pnpm', ['install', '--frozen-lockfile']) + run(process.execPath, ['.github/scripts/verify-docx-upstream.mjs']) + run(process.execPath, ['.github/scripts/verify-public-release-facts.mjs']) + console.log( + `[release-docx] ${previous} -> ${version}; manifest, lockfile, Worker provenance and current documentation synchronized. Run pnpm release:verify, then commit the changes. Nothing was published.` + ) + } catch (error) { + await Promise.all( + Object.entries(texts).map(([path, text]) => writeFile(resolve(root, path), text)) + ) + console.error( + '[release-docx] Source metadata restored after failure. Run pnpm install --frozen-lockfile to restore installed dependencies.' + ) + throw error + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main() diff --git a/.github/scripts/prepare-docx-upstream.test.mjs b/.github/scripts/prepare-docx-upstream.test.mjs new file mode 100644 index 000000000..295cd9000 --- /dev/null +++ b/.github/scripts/prepare-docx-upstream.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' +import { prepareDocxTexts } from './prepare-docx-upstream.mjs' +const paths = [ + 'packages/renderers/word/package.json', + 'packages/core/src/platform/assets.ts', + 'pnpm-workspace.yaml', + 'apps/official-site/public/llms.txt', + 'apps/official-site/public/llms-full.txt', + 'docs/guide/faq.md', + 'docs/zh/guide/faq.md' +] +const texts = Object.fromEntries( + await Promise.all(paths.map(async (path) => [path, await readFile(path, 'utf8')])) +) +test('upstream update keeps package, Worker provenance and current version facts synchronized', () => { + const { result, previous } = prepareDocxTexts(texts, '99.88.77') + assert.match(previous, /^\d+\.\d+\.\d+$/) + assert.equal(JSON.parse(result[paths[0]]).dependencies['@file-viewer/docx'], '99.88.77') + assert.match(result[paths[1]], /DEFAULT_FILE_VIEWER_DOCX_RUNTIME_VERSION = '99.88.77'/) + for (const path of paths.slice(2)) assert.ok(result[path].includes('99.88.77'), path) + assert.notEqual(texts[paths[0]], result[paths[0]], 'inputs remain immutable') +}) +test('upstream update rejects ranges, URLs, tags and shell syntax', () => { + for (const value of [ + 'latest', + '^0.3.32', + 'file:./x.tgz', + '1.2.3;echo bad', + '1.2.3-rc.1', + '01.2.3' + ]) + assert.throws(() => prepareDocxTexts(texts, value)) +}) +test('mismatched Worker version fails instead of changing unrelated release facts', () => { + assert.throws( + () => prepareDocxTexts({ ...texts, [paths[1]]: '' }, '99.88.77'), + /Worker provenance/ + ) +}) diff --git a/.github/scripts/verify-docx-upstream.mjs b/.github/scripts/verify-docx-upstream.mjs new file mode 100644 index 000000000..ed634d8ee --- /dev/null +++ b/.github/scripts/verify-docx-upstream.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import JSZip from 'jszip' +import { JSDOM } from 'jsdom' + +// A behavioral release gate: an npm version string alone does not prove that +// the packed browser renderer contains the upstream table-border repair. +export async function verifyDocxUpstream( + root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +) { + const require = createRequire(resolve(root, 'packages/renderers/word/package.json')) + const docx = require('@file-viewer/docx') + const manifest = require('@file-viewer/docx/package.json') + const dom = new JSDOM('
') + const names = [ + 'window', + 'document', + 'DOMParser', + 'XMLSerializer', + 'Node', + 'HTMLElement', + 'getComputedStyle' + ] + const previous = new Map( + names.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]) + ) + try { + for (const name of names) + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value: + name === 'getComputedStyle' + ? dom.window.getComputedStyle.bind(dom.window) + : dom.window[name] + }) + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + '' + ) + zip.file( + '_rels/.rels', + '' + ) + zip.file( + 'word/document.xml', + 'UPSTREAM_RELEASE_GATE' + ) + const host = dom.window.document.getElementById('root') + await docx.renderAsync(await zip.generateAsync({ type: 'nodebuffer' }), host, null, { + useWorker: false, + breakPages: false, + awaitLayout: false, + ignoreFonts: true + }) + assert.match(host.textContent, /UPSTREAM_RELEASE_GATE/) + const diagonal = host.querySelector('[data-docx-diagonal="tl2br"]') + assert.ok( + diagonal, + `Installed @file-viewer/docx@${manifest.version} lacks the upstream diagonal-border fix. Publish the merged upstream first, then run release:prepare-docx.` + ) + assert.equal(diagonal.getAttribute('x1'), '0%') + assert.equal(diagonal.getAttribute('x2'), '100%') + assert.equal(diagonal.closest('svg').style.pointerEvents, 'none') + const result = { + package: manifest.name, + version: manifest.version, + diagonalCount: 1, + passed: true + } + console.log('[docx-upstream]', JSON.stringify(result)) + return result + } finally { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else delete globalThis[name] + } + dom.window.close() + } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + await verifyDocxUpstream() diff --git a/apps/viewer-demo/scripts/verify-issue-266-samples.mjs b/apps/viewer-demo/scripts/verify-issue-266-samples.mjs index 416e1f748..a2bab5222 100644 --- a/apps/viewer-demo/scripts/verify-issue-266-samples.mjs +++ b/apps/viewer-demo/scripts/verify-issue-266-samples.mjs @@ -87,6 +87,7 @@ try { assert.equal(row.failure, undefined) assert.equal(row.pages, 13) assert.equal(await page.locator('table').count(), 4) + if (process.argv.includes('--require-diagonals')) assert.equal(await page.locator('[data-docx-diagonal="tl2br"]').count(), 1, 'Packed upstream diagonal fix must reach the actual Word renderer') row.borders = await page.locator('table').first().locator('td').first().evaluate(cell => ['Top', 'Right', 'Bottom', 'Left'].map(side => getComputedStyle(cell)[`border${side}Width`])) assert.ok(row.borders.every(width => Number.parseFloat(width) > 0), 'Existing ordinary cell borders must be preserved') @@ -133,9 +134,9 @@ try { row.errors = errors; row.warnings = warnings; row.networkRequests = requests assert.deepEqual(errors, []) assert.deepEqual(requests, [], 'In-memory document rendering must not make HTTP requests') - // This invoice already has an unsupported SES signature envelope. Keep the - // warning visible in the report rather than presenting it as a new success. - assert.ok(warnings.every(warning => format === 'ofd' && row.pages === 1 && warning.includes('unsupported SES signature structure')), warnings.join('\n')) + // Only the unchanged baseline misclassifies the invoice SignedData as SES. + if (baseline) assert.ok(warnings.every(warning => format === 'ofd' && row.pages === 1 && warning.includes('unsupported SES signature structure')), warnings.join('\n')) + else assert.deepEqual(warnings, [], 'Patched samples must render without signature warnings') console.log(`[issue-266] ${stage} ${format}: ${row.pages ?? 'expected failure'} pages; ${file.name}`) } finally { await page.close() } } diff --git a/docs/maintenance/release-readiness-20260911.md b/docs/maintenance/release-readiness-20260911.md new file mode 100644 index 000000000..b5f219632 --- /dev/null +++ b/docs/maintenance/release-readiness-20260911.md @@ -0,0 +1,40 @@ +# Release preparation: sample regressions + +## Source repairs + +- File Viewer #272: literal PPTX chart data and horizontal-bar orientation (issue #268). +- File Viewer #273: Word 2003 XML containers, mixed DrawingML anchors and OFD TextCode positions (issue #266). +- docxjs #10: direct cell diagonal borders; merged upstream. The npm dependency must contain that repair before a File Viewer release is qualified. +- OFD SignedData: distinguish PKCS#7 / GM/T ContentInfo from an SES seal. The invoice in #266 contains a digital signature and its visible stamp is already an ordinary page resource. Do not try to extract an SES image from its certificate envelope. Neither SignedData nor a displayed SES image implies cryptographic verification; `VerifyRet` is now `null` and `VerificationStatus` is `not-verified`. + +## Publication order + +This preparation does not publish either package, create a release, or change issue state. + +1. Publish the reviewed, merged `flyfish-dev/docxjs` source under a **new version**. Do not republish the existing 0.3.31 version. +2. In the clean File Viewer checkout, run `pnpm release:prepare-docx `. This checks registry presence before mutation, synchronizes the dependency, lockfile, core Worker provenance/cache token and current version documentation, then behaviorally verifies the installed package. Metadata is restored if preparation fails. +3. Run `pnpm release:verify`, followed by the normal full Public CI/browser matrix and original-file checks below. Commit the preparation changes with the planned File Viewer version/release metadata before creating the immutable release assets. +4. Publish the new File Viewer packages using the existing release pipeline. + +`pnpm verify:docx-upstream` deliberately fails against an old engine that does not render direct cell diagonals. A successful source build is not a substitute for this consumer check. Keeping the currently published dependency until step 2 allows ordinary frozen-lockfile installs to work before the new upstream exists. + +## Reproduction evidence + +Supply the original public issue #266 ZIP (SHA-256 `57345ed8469bfae8ccb066abb726a551d0c322f5af7527828771035551cff4cb`) and issue #268 PPTX (SHA-256 `4b0dfef0400a6194f86c3deb1234fdf84828f696f2f3915fe940cbb43f5ed30c`). Scripts do not download or upload documents. + +```sh +OFD_SIGNATURE_SAMPLE_ZIP=/path/to/issue-266.zip pnpm test:ofd-signatures +node apps/viewer-demo/scripts/verify-issue-266-samples.mjs /path/to/issue-266.zip +node packages/renderers/pptx/scripts/verify-github-268-browser.mjs /path/to/issue-268.pptx +``` + +The patched #266 gate now requires zero console warnings, not an allowlist for the old invoice warning. The deterministic SignedData tests cover both OIDs, malformed envelopes, unknown OIDs, truncated DER and missing entries. The original-file gate also checks that the existing SES image still contains 12,884 bytes. + +## Scope that must not be called fixed without evidence + +- #227: the original sensitive XLS is not available in the public issue. The existing MiniFAT regression is synthetic, not the original report. +- #248: the inline component lacks the attachment and the `downloadAttach` implementation. Existing cold Vue CLI/package regression coverage does not prove that private request/response path. +- #269: the screenshot does not include the CAD file or font resources needed for reproduction. +- #267: optional IFC visualization is a separate feature request; it must not be marked complete merely because signature detection exists. + +No issue is closed automatically by these changes. diff --git a/package.json b/package.json index 0ae224e7f..c63024255 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:vite-copy-assets-github-257": "pnpm --filter @file-viewer/vite-plugin verify:github-257", "test:word-revisions-github-255": "pnpm exec vitest run test/word-revisions.spec.ts test/word-native-revisions.spec.ts test/vue3-render-options.spec.ts", "test:thumbnail": "pnpm --filter @file-viewer/thumbnail test", - "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:html-preview-github-256 && pnpm test:ofd-github-177 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:vite-copy-assets-github-257 && pnpm test:word-revisions-github-255 && pnpm test:word-github-266 && pnpm test:ofd-github-266", + "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:html-preview-github-256 && pnpm test:ofd-github-177 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:vite-copy-assets-github-257 && pnpm test:word-revisions-github-255 && pnpm test:word-github-266 && pnpm test:ofd-github-266 && pnpm test:ofd-signatures", "type-check:components": "pnpm --filter @file-viewer/web type-check && pnpm --filter @file-viewer/react type-check && pnpm --filter @file-viewer/react-legacy type-check && pnpm --filter @file-viewer/vue3 type-check && pnpm --filter @file-viewer/vue2.7 type-check && pnpm --filter @file-viewer/vue2.6 type-check && pnpm --filter @file-viewer/jquery type-check && pnpm --filter @file-viewer/svelte type-check", "type-check:tools": "pnpm --filter './packages/tools/**' type-check", "docs:dev": "pnpm --filter @flyfish-group/file-viewer-docs dev", @@ -85,7 +85,11 @@ "test:pptx-github-268": "pnpm --filter @file-viewer/pptx verify:github-268", "test:word-github-266": "pnpm --filter @file-viewer/core build && pnpm --filter @file-viewer/doc build && pnpm --filter @file-viewer/renderer-word build", "test:ofd-github-266": "pnpm --filter @file-viewer/renderer-ofd verify:github-266", - "verify:github-266-browser": "node apps/viewer-demo/scripts/verify-issue-266-samples.mjs" + "verify:github-266-browser": "node apps/viewer-demo/scripts/verify-issue-266-samples.mjs", + "test:ofd-signatures": "node packages/renderers/ofd/scripts/verify-signature-containers.mjs", + "verify:docx-upstream": "node .github/scripts/verify-docx-upstream.mjs", + "release:prepare-docx": "node .github/scripts/prepare-docx-upstream.mjs", + "release:verify": "pnpm verify:docx-upstream && pnpm build && pnpm type-check && pnpm test" }, "devDependencies": { "@types/node": "^26.4.0", diff --git a/packages/renderers/ofd/scripts/verify-signature-containers.mjs b/packages/renderers/ofd/scripts/verify-signature-containers.mjs new file mode 100644 index 000000000..07ce2680b --- /dev/null +++ b/packages/renderers/ofd/scripts/verify-signature-containers.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import JSZip from 'jszip' +import { parseSesSignature } from '../vendor/dltech/ofd/ses_signature_parser.js' + +const tlv = (tag, bytes) => { + const value = Buffer.from(bytes) + const length = value.length < 128 ? [value.length] : [0x82, value.length >> 8, value.length & 255] + return Buffer.concat([Buffer.from([tag, ...length]), value]) +} +const seq = (...children) => tlv(0x30, Buffer.concat(children)) +const integer = value => tlv(2, [value]) +const set = (...children) => tlv(0x31, Buffer.concat(children)) +const oid = hex => tlv(6, Buffer.from(hex, 'hex')) +const oids = ['2a864886f70d010702', '2a811ccf550601040202'] +const contentInfo = (type, content) => seq(oid(type), tlv(0xa0, content)) +const signedData = () => seq(integer(3), set(seq(oid('2a811ccf55018311'))), seq(oid('2a864886f70d010701')), set()) +async function parse(bytes) { + const warnings = [] + const warn = console.warn + console.warn = (...args) => warnings.push(args.map(String).join(' ')) + try { + const zip = new JSZip().file('SignedValue.dat', bytes) + return { value: await parseSesSignature(zip, 'SignedValue.dat'), warnings } + } finally { console.warn = warn } +} +for (const [index, type] of oids.entries()) { + test(`${index ? 'GM/T' : 'PKCS#7'} SignedData is recognized without inventing a seal or claiming verification`, async () => { + const { value, warnings } = await parse(contentInfo(type, signedData())) + assert.deepEqual(warnings, []) + assert.equal(value.type, 'signed-data') + assert.equal(value.verifyRet, null) + assert.equal(value.verificationStatus, 'not-verified') + assert.equal(value.SES_Signature.displayOnly, true) + assert.equal(value.ofdArray, undefined) + }) +} +test('matching OID with a malformed SignedData envelope is not silently accepted', async () => { + for (const value of [seq(), seq(integer(3)), seq(integer(3), set(), seq(), set())]) { + const result = await parse(contentInfo(oids[0], value)) + assert.deepEqual(result.value, {}) + assert.equal(result.warnings.length, 1) + assert.match(result.warnings[0], /Malformed SignedData/) + } +}) +test('an unknown ContentInfo OID remains a visible unsupported format', async () => { + const result = await parse(contentInfo('2a864886f70d010703', signedData())) + assert.deepEqual(result.value, {}) + assert.equal(result.warnings.length, 1) + assert.match(result.warnings[0], /Unsupported signature ContentInfo/) +}) +test('truncated DER cannot masquerade as a parsed signature', async () => { + const valid = contentInfo(oids[1], signedData()) + const result = await parse(valid.subarray(0, valid.length - 2)) + assert.deepEqual(result.value, {}) + assert.equal(result.warnings.length, 1) +}) +test('missing SignedValue entry returns no visual stamp', async () => { + assert.deepEqual(await parseSesSignature(new JSZip(), 'absent'), {}) +}) +if (process.env.OFD_SIGNATURE_SAMPLE_ZIP) { + test('original issue #266 invoice SignedData and existing SES seal both parse without warnings', async () => { + const input = await readFile(process.env.OFD_SIGNATURE_SAMPLE_ZIP) + assert.equal(createHash('sha256').update(input).digest('hex'), '57345ed8469bfae8ccb066abb726a551d0c322f5af7527828771035551cff4cb') + const archive = await JSZip.loadAsync(input) + const seen = [] + for (const file of Object.values(archive.files).filter(file => file.name.endsWith('.ofd'))) { + const zip = await JSZip.loadAsync(await file.async('uint8array')) + const result = await parse(await zip.file('Doc_0/Signs/Sign_0/SignedValue.dat').async('uint8array')) + assert.deepEqual(result.warnings, []) + assert.equal(result.value.verifyRet, null) + if (result.value.type === 'signed-data') assert.equal(result.value.ofdArray, undefined) + else { assert.equal(result.value.type, 'png'); assert.equal(result.value.ofdArray.length, 12884) } + seen.push(result.value.type) + } + assert.deepEqual(seen.sort(), ['png', 'signed-data']) + }) +} diff --git a/packages/renderers/ofd/vendor/dltech/ofd/ofd_parser.js b/packages/renderers/ofd/vendor/dltech/ofd/ofd_parser.js index 166c0522c..11982a622 100644 --- a/packages/renderers/ofd/vendor/dltech/ofd/ofd_parser.js +++ b/packages/renderers/ofd/vendor/dltech/ofd/ofd_parser.js @@ -654,6 +654,7 @@ const getSignatureData = async function (zip, signature, signatureID) { 'signedInfo': { 'signatureID': signatureID, 'VerifyRet': sealObj.verifyRet, + 'VerificationStatus': sealObj.verificationStatus || 'not-verified', 'Provider': signedInfoNode['ofd:Provider'], 'SignatureMethod': signedInfoNode['ofd:SignatureMethod'], 'SignatureDateTime': signedInfoNode['ofd:SignatureDateTime'], diff --git a/packages/renderers/ofd/vendor/dltech/ofd/ses_signature_parser.js b/packages/renderers/ofd/vendor/dltech/ofd/ses_signature_parser.js index 9b564d485..577685dee 100644 --- a/packages/renderers/ofd/vendor/dltech/ofd/ses_signature_parser.js +++ b/packages/renderers/ofd/vendor/dltech/ofd/ses_signature_parser.js @@ -33,6 +33,38 @@ const HexApi = unwrapDefault(Hex); const Base64Api = unwrapDefault(Base64); const ASN1Api = unwrapDefault(ASN1); +// SignedData is not an SES seal. In particular, tax invoices use the GM/T +// ContentInfo OID and draw their visible stamp as an ordinary page resource. +const signedDataOids = new Set(['1.2.840.113549.1.7.2', '1.2.156.10197.6.1.4.2.2']); +const universal = (node, number) => node?.tag?.tagClass === 0 && node.tag.tagNumber === number; +const parseSignedData = function (node) { + if (!universal(node?.sub?.[0], 6)) return null; + const oidNode = node.sub[0]; + const start = oidNode.stream.pos + oidNode.header; + const oid = oidNode.stream.parseOID(start, start + oidNode.length).split('\n', 1)[0]; + if (!signedDataOids.has(oid)) throw new Error('Unsupported signature ContentInfo type'); + const wrapper = node.sub[1]; + const signed = wrapper?.sub?.[0]; + if (!universal(node, 16) || node.sub.length !== 2 || + wrapper?.tag?.tagClass !== 2 || wrapper.tag.tagNumber !== 0 || + !wrapper.tag.tagConstructed || wrapper.sub.length !== 1 || + !universal(signed, 16) || signed.sub.length < 4 || + !universal(signed.sub[0], 2) || !universal(signed.sub[1], 17) || + !universal(signed.sub[2], 16) || !universal(signed.sub[2]?.sub?.[0], 6) || + !universal(signed.sub.at(-1), 17)) { + throw new Error('Malformed SignedData structure'); + } + return { + type: 'signed-data', + verificationStatus: 'not-verified', + verifyRet: null, + SES_Signature: { + format: 'signed-data', contentType: oid, displayOnly: true, + verificationStatus: 'not-verified', + }, + }; +}; + export const parseSesSignature = async function (zip, name, getEntry) { const entry = typeof getEntry === 'function' ? getEntry(zip, name) : zip.files[name]; if (!entry) { @@ -60,7 +92,10 @@ const decodeText = function (val) { const decode = function (der, offset) { offset = offset || 0; try { - const SES_Signature = decodeSES_Signature(der, offset); + const root = ASN1Api.decode(der, offset); + const signedData = parseSignedData(root); + if (signedData) return signedData; + const SES_Signature = decodeSES_Signature(root); const picture = SES_Signature?.toSign?.eseal?.esealInfo?.picture; if (!picture?.data?.byte?.length) { return {}; @@ -73,12 +108,15 @@ const decode = function (der, offset) { SES_Signature: { realVersion: SES_Signature.realVersion, displayOnly: true, + verificationStatus: 'not-verified', pictureType: type, pictureWidth: picture.width, pictureHeight: picture.height, sealName: SES_Signature.toSign?.eseal?.esealInfo?.property?.name, }, - verifyRet: true, + // Displaying an image is not cryptographic signature verification. + verifyRet: null, + verificationStatus: 'not-verified', }; } catch (e) { console.warn('[ofd] decode SES signature failed', e); @@ -122,9 +160,7 @@ const parseTimeNode = function (node, utc) { return decodeUTCTime(raw); }; -const decodeSES_Signature = function (der, offset) { - offset = offset || 0; - const asn1 = ASN1Api.decode(der, offset); +const decodeSES_Signature = function (asn1) { let SES_Signature; try { // V1 From 653d2fb4f962d6bbf5e413dd5bca9bc1d9091dac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:52:06 +0000 Subject: [PATCH 2/5] feat(ifc): add optional local BIM viewer with owned Worker lifecycle Refs #267. Includes pinned official IFC4/4.3 browser gates, selection, properties, extension cleanup, cold-installed CLI and self-hosted Worker/WASM assets. Ordinary model and Office entries are unchanged. --- docs/guide/on-demand-renderers.md | 8 + .../maintenance/release-readiness-20260911.md | 13 +- docs/regressions/issue-267/ifc4.ifc.png | Bin 0 -> 55504 bytes docs/regressions/issue-267/ifc43.ifc.png | Bin 0 -> 55486 bytes docs/regressions/issue-267/report.json | 129 +++++ package.json | 5 +- packages/renderers/3d/IFC.md | 100 ++++ packages/renderers/3d/bin/copy-ifc-assets.mjs | 183 +++++++ .../3d/licenses/thatopen-fragments-MIT.txt | 18 + packages/renderers/3d/package.json | 47 +- .../3d/scripts/download-ifc-fixtures.mjs | 42 ++ .../3d/scripts/verify-ifc-browser.mjs | 292 +++++++++++ .../renderers/3d/scripts/verify-ifc-entry.mjs | 74 +++ .../renderers/3d/src/ifc-import.worker.ts | 34 ++ packages/renderers/3d/src/ifc.ts | 87 ++++ packages/renderers/3d/src/ifcRuntime.ts | 484 ++++++++++++++++++ pnpm-lock.yaml | 98 ++++ pnpm-workspace.yaml | 1 + 18 files changed, 1607 insertions(+), 8 deletions(-) create mode 100644 docs/regressions/issue-267/ifc4.ifc.png create mode 100644 docs/regressions/issue-267/ifc43.ifc.png create mode 100644 docs/regressions/issue-267/report.json create mode 100644 packages/renderers/3d/IFC.md create mode 100755 packages/renderers/3d/bin/copy-ifc-assets.mjs create mode 100644 packages/renderers/3d/licenses/thatopen-fragments-MIT.txt create mode 100644 packages/renderers/3d/scripts/download-ifc-fixtures.mjs create mode 100644 packages/renderers/3d/scripts/verify-ifc-browser.mjs create mode 100644 packages/renderers/3d/scripts/verify-ifc-entry.mjs create mode 100644 packages/renderers/3d/src/ifc-import.worker.ts create mode 100644 packages/renderers/3d/src/ifc.ts create mode 100644 packages/renderers/3d/src/ifcRuntime.ts diff --git a/docs/guide/on-demand-renderers.md b/docs/guide/on-demand-renderers.md index d83d3d7fd..c7a3c8337 100644 --- a/docs/guide/on-demand-renderers.md +++ b/docs/guide/on-demand-renderers.md @@ -288,3 +288,11 @@ Use `copyAssets:true` or `npx --yes file-viewer-copy-assets ./public/file-viewer - [x] The Rust/WASM parser runs in a dedicated Worker, and standard asset tooling self-hosts the Worker, JavaScript bridge, and WASM binary under `vendor/chm/`. - [x] Contents, keyword index, text search, internal navigation, and packaged resources stay within the current archive. - [x] Topic documents are sanitized, scripts remain disabled by sandbox and CSP, and remote active content is not loaded automatically. + +## Optional IFC BIM viewer + +The explicit `@file-viewer/renderer-3d/ifc` entry enhances only IFC files with +local That Open / Web-IFC visualization, selection, properties and a configuration +hook. Existing Full/Office entry points remain unchanged. Install the optional +peers and self-host their matching Worker/WASM assets with `file-viewer-ifc-assets`. +The exact setup and licensing notes are in the package's [IFC guide](https://github.com/flyfish-dev/file-viewer/blob/main/packages/renderers/3d/IFC.md). diff --git a/docs/maintenance/release-readiness-20260911.md b/docs/maintenance/release-readiness-20260911.md index b5f219632..34fd94a04 100644 --- a/docs/maintenance/release-readiness-20260911.md +++ b/docs/maintenance/release-readiness-20260911.md @@ -18,6 +18,17 @@ This preparation does not publish either package, create a release, or change is `pnpm verify:docx-upstream` deliberately fails against an old engine that does not render direct cell diagonals. A successful source build is not a substitute for this consumer check. Keeping the currently published dependency until step 2 allows ordinary frozen-lockfile installs to work before the new upstream exists. +## Packed upstream integration + +The actual merged upstream source `6dbe15e347459f3707116d531fc9064f2d4c2a95` +was built and packed, then installed into a disposable checkout of File Viewer +`161d81379a8de7e2edf17a87e3f24eb0f3dc063f`. The old public package fails the +behavioral gate; the candidate tarball passes. Original #266 samples passed +through the actual Word renderer with `--require-diagonals`, four stamp anchor +positions and no OFD warnings. The source dependency and lockfile were not changed +by this test. Private upstream CI run: `34620182131` (successful). +This is pre-publication candidate evidence, not proof of a future npm artifact. + ## Reproduction evidence Supply the original public issue #266 ZIP (SHA-256 `57345ed8469bfae8ccb066abb726a551d0c322f5af7527828771035551cff4cb`) and issue #268 PPTX (SHA-256 `4b0dfef0400a6194f86c3deb1234fdf84828f696f2f3915fe940cbb43f5ed30c`). Scripts do not download or upload documents. @@ -35,6 +46,6 @@ The patched #266 gate now requires zero console warnings, not an allowlist for t - #227: the original sensitive XLS is not available in the public issue. The existing MiniFAT regression is synthetic, not the original report. - #248: the inline component lacks the attachment and the `downloadAttach` implementation. Existing cold Vue CLI/package regression coverage does not prove that private request/response path. - #269: the screenshot does not include the CAD file or font resources needed for reproduction. -- #267: optional IFC visualization is a separate feature request; it must not be marked complete merely because signature detection exists. +- #267: the optional IFC entry is documented in `packages/renderers/3d/IFC.md`; its official-model browser gate must pass before the optional feature is qualified. The frozen default Full/Office profiles are unchanged. No issue is closed automatically by these changes. diff --git a/docs/regressions/issue-267/ifc4.ifc.png b/docs/regressions/issue-267/ifc4.ifc.png new file mode 100644 index 0000000000000000000000000000000000000000..cf66851016ca264f73fb1a8df7058853306b8a9a GIT binary patch literal 55504 zcmd42WmH_1C*0_d zkx$sF{u)L>d5Iz`@lM?}Wp4pPTTN?`{eCh}w$@)BQ$;pRh3$p5M4-l&N-5$ia5OS# z7OnEdkg|2tX!?kIUVPY=E+d!vgq$1NsHQ8E@iBoQp*zzSZY654dh6Fu!p|9e?VmsG z39~_X(0_M*q*Bm12tVcLneo_{=iLVRToqMal)}7kLXjOo{z^L`=ntjfzt$cK1^@2~ zn53@bY^lw~Wg6p)&ZqH)&q=X7?v*qN>8SlQ>GX!4kXGDV3M3Pp66IMsEsCG+jd=bwryb7mOY6a)YL}*>sYodA22=dV(QgWv=Kid6<)m*>7NAmA&~ESe1Bf!yEJos_HP;*?2Z>Bh;oICjhyejRZXQ z(fcsX|N9K#3`_7EsZ=7EOm~9x!NtS}b!U{#l=b}bt~)1)`-Afc~=3J8O$J$^-6R_+Y5Y7i{p|ZmwF2oc>#ITuh#TZ`a5BdAWMP zs5xaJcEX0u`bdRuEp2sw>zJhQuza)79+8fxVYmd-X@*9a}oubLmH77)8x+mj)pMjLa}(}#YX-mT|WSd<)paoQYK zPh`vn_uBcP4rBxNhV{W9t%gGi&{>UF=i~O0yseTihzlXaK5_wx%Uz@LD(;0>QJ*6Y z&W!nb`9_ALn>)+#UihBLgL&nAcRwAW1ENc1LQlJM3K90<-rd}mFgHQp+@tt`26;$ zClEtgvreNcpea_f$Rv(3L=Qo`-g20X52L%Rp%@e`;apT6tBL`)M@?|?9Uz(+Y+#;_ z9FDv3JW9C@2|x$7nv}yjkpDy$%%v!IrG1Km{*-T%AhqWvpu`e-tvKWCPp;fSyVXvO zaH0Zu#|Gc7CM+Q8&YPf<_n`N;8~3Y;x#5KuYw%eONq6h0c`m{2o0FB@b@~hG#@0f0 zZ~Qt>`E-ttuw#4}<$IoVM|hBpANZbh5vpOy9H>izMFmwC5*7*gkbHL&gb zm$WF-#(%zd*2sFsar|K;1nx*mhuU;t?56ci^swA^_YiP^)66s3i&LzR@6jp1jXh`N!gdJK; zdeYf)OJ2i7$lAb?7vkzb-)ON#tP>EPJc5j5t&2MBmtC`{29v`%76edy-ZzXTIHnWr zRcNMu>CcDQ6PzD%KJX8H=Rz<;Zwb=psq2mz(;!_;+2+vWWc(yvX$L<-Q=BZzgf6_v zXuIo@^;JEL;ENB7^+|-sT)(rloI_V?_4#|UvW%lbhR@_Yz-STJwhZ+CrQ*agY#zse zxzM_Zd3^o~rv~4nxtR_`cbJ>O+|`DzpK_!_u;XSo%)FsoU;T{0)SBBUY9eSWU&%E7 z=yuy`UD1#X`o7>zBWQE$<@AljO|4u$q+eQEk@rIi}%$kL#zzwW$KebvO-nDeGE*$ai+7iGLugY zsztD{oln<6hM?7?aXKu<7oDCi_{$6hH+MoGI2kY7d7vh0FfWJbr0t*e4hz=;j|XD~ z*JCq&^h(>+tRjOo{qw!_l7cL?0#IBSHcq`m%ia4e94kg#jQY7#X~7q=0;y-;KVL1M zczD;IA%1FNNg`m~f5UHO>buv@4#;|L%{s?y3r*n;Hua2E7Dl51prtX`8 z12xc{FNp35D1~oY0Rp$u0%}fWK&n0;SX zm~?luf!X3DZL=1*Dw}gHcpwLGl{+Aq0EhEXKy5E&YL-|N5l5BvCX%bYwfntQ&6IQ! zZh|3|;{-+eFO?{)Yt5%GGL5q2&rbuh$dju(8|pfM)s$=(2@MG4E#HRwRF#VqaY`*& z4TiO?&HYL>h4GFTs6%x1e*EfukGS^DBeA0 zKWuq-Vw2+gWXR-=c_CeWebnCXK!Rs#b%fr#9l3K+Fnxhgx$f8(&d?BpCg62r|C<`~ zYp+H`?I{at)N&dr_P+aFF)lRe(S`Q1to@TCUy;;+FOJ)=Fz}>-o$ExeXA#-PfK#nE z3lHSAU`aGYX3)+#m22*c`N|SN*YnQaUFDmR=TKnf?Lp*UZA1nTc7~brVRq&an7Y2W zq-@{`W9YND4ymWM5kHQ;P1^1ExBRr;H4aPjvDfvSBLDVP$}QmXo`!=ui z>-MU2OBKoPzKE=4H~nO87^EadmC>1lBKPte6Sm|ZRstKEXFltqVz_)btnMb zxkete-pZCoM!&&65rfnE@*gLxFF#K}b)lkycz3wG}W(0usSaJ;w?A&EizMRwA*mmvA258nI=hKBV1t;`+r@vnGC!N(q( zQc_;!Ge@LJk9<;n)8xNNr-s~OcrE1kD339#UX-Dw*4smtF_sueTaLIEQhtJSS8Di? z``y!Z6S=ZO~wWe_^Ia_)43Cy_)wG0ERMkKn;g_+(n;p8~=lYmdKZ>?Q^{d<=A+a(Q~$?sW40_Q8JuAda=V-ZA21 zF(Md~_Re#S%9STA2XNA7>GBLc){)IqcsBl+qVs|61t#EQW-ch^&??jMtnDckC%fUIvN( zCnn@m&?L;!7ZiETSqRw=M-FCjFU+&l)cs z|E+?*z9#p}KQ$1~lhL|7eYPQBLofOJ!LtHE4X%TIl%@0^4G#eLznS&_a~=MtpOk+6 zCo+y4pMt1IrUR)F!}lH2Hh~aR#Jt=y`i0Kgmm8bw{NV9A-fOSjx_z~Tn)1(H0C1%A z@udgK6tUFr(S0@r0Vo%pmn2A+mE!N+hWMEub5#C_`omzrq1ofOpUq$Xne9@=`0}F< z3LUSs5>dP zE-#4GkYdv(?Gc@5NG-4NHFu8Me67X1U(D6?X7>}byFUV%#c~vkc_V>Iq_@A0EwS9l z^!siXg)p@vb$;%Jk?Hd!p6VyAt4(e25yvrbYLSY@90u)m`R_4+OR(r?F)3Hhriida zYBvt~4AAu3>nf`-HtI=v!SLt$$y2Aews6W|+5V9C!tsmC9qTheLMB5+PX>boO!rI( z0ZlhH)TbBSms*%m7jF_Tetlz=YI|Zc`~5$l&A}nzh_^cGD-3by;1(445rrhSE;I5`@Ou=2JNgkpw*VIxvHOYNgwDrI6p zRul>u-B@%Ys&}n(2o7R!c9YbHVp@oID!ni(yS}a4 z)#qX%l`CN5vXgeNM^})UTi-idBXfqwsSA1i0)*>(y^oKnUO~k_c^XSEIcQ;Z=zX_; zP$g_JMBhAsHgOwMD{Sn+IO!x8FJA%E)pk4{zZ1Ii2iVsjL8N#bWs|3s$z*JBcd;JJ z<_&w&+xfLcJ1x^+8|y^F^m|GPTswi@+O_QJzt8+3*AK%9DY)w^8`?3~`&xlAKfd?W}Yi?(-V`^AWGS{b{d zwn!hKWm1A2AMZQ*E?=IZU7hv#-HM-~y;^ib$3L!pZoJXC?xew_uX`m>cRb6;w01Vv zpY30B!R{Ko-L4BnUr5se`Pb>K(>DVqynY3bg+qKANuW=*XIZ?&q1iNrNj5TUeQMqE z3C3Z?f?T4D5%wJ=w<1CaiA8Cr`Gj*QAY4d!>?Y;_^2RwRC|Dtb^dLIQ(E1WPrS1sO zAc{$B(dOAO77AnQzDhm~0vhxw=>R6b4taN1cS4l$T(6aTe>*0ZW#ISgPp8SnjchpQ z@rekQ3>ootXRJu3W>yw+P2GVo?eqFw2C)qZ$+fg;^^8n53L#pL9P`iBb+4Ee=%g zJ@13AHH*t%c{DD8v^ulQ157rXNtaod@q3dd=}+H?mSi77o%%JJ4c1gDH8;`Z6wL+N%O6l+6ro9{M6w4G)oe)(4`I~NV*{iyw1&IW{ic? z;NDLY33tYMRS}>`c$G)8EmS6^$N?PG6=x@2#D*P}5Us1fg~SUPIfEkT^`XW=g4r-qt)K`f&D_eH&-YY9u|Db+BP5^?ZrH5@Xocu+is0)_) z)L>xX)&$Sj)X*(y3Il=ma|2@6ee+bk!*tYiHMDhHRb+j+HlCeq-5#CxjzQQ+@(FFZ zQW8TludXU;+IS%i@D7HB_F;3RuK)%Foaovuyty!!hRJ*Olg`Q3_rD zc$w*Yd(bcEgNB|yE~CqQ=d|-&z)Mdf5VnK$=mhRq9P8szd8;cHg=vmg&up`bQPZq1 zq2{sgX>%9~V!~2er9J{t{gDJ3j$FGuU=0VKuRK@pY?H-iq;hmP#gAewR}}$1wVnIK zVk!$YwMl}V1BR}rf=i9jnGx*5sTjd_;YiiGn3#LF%!obxV3a>ENGZ(S!65!B&)un^Pty;6ob+V5t$2lh|++0iyAw4B&@sy*s?Q&+lgzs*f`f9yMa9o_EqLO#}z z7klRi!qZ9XbpTZi30S6>48fnLoF0WR*IIci?)CX_r8i_~d3=&-qbR@J7aO487xm2z zdT`w+CeA7jWw5JthS>5Tin6ABLEc zoSdFAZ0f@2;=iFUS@Y4-Msx#Fl*1o<=~Spci9FJ*YvsUy};QdzR0nH zeyT-oJZMYE&0dxfUL#vDsH;qB*ItCGH%WzK?=Qt=s8vn2n3fS*_7uAe@|38(j-0!YcKf9aHPlH^WX{%4iyw zOYB@v*G?9$Y@p#bI-V@RzpT|r53T828bX90FV;dczbW~WGi1!3Mo%>+fUN4WwI)b> za!T7hsO-D8hQ~`HnWeuqhAo=oc;;fkEXETduKb8);Kh_v%uUKJ6BYg22yB3+3W zjrv)v@BN+ToJQN-zZe@(BU%@w>U}dT0J}e(I*B~ZfTi?yB%|qRq;mRztw%4kzhnk~ zM%q*7PX*g%0%7wUh}@bmgYlT0fJ?I%0r(o3=}+eD5di$lClBGO_!A|POIe+Z z0H?;T7npp@ddJ$sN8#9}_wCT>UjrRRLqnBx;386)Feg}ZGN}dm2Gil?A@xn$rtyDVd!FLo6*B=V<3dS7) z28f^v2Nc|4$F(&rO;ZZQ<)Ca=d3ZMX>>~Uzrnn(Gly&J4((JLRKG~>p%3F*9VXEE5 zQ$P3#QS`eYx+;0&)x-uEf1u^W8E3?-u`&X>%@VgIOF_<8l|H@nLKKoqI zN4YEHfixK=bwxZH3T0ZK zM9|IW=cfRnF{yS;sk%_t{S7$uJgxP1;sLyPD6VE$)W7>79s%@bagja|2=eYF6&+LA z+cT2|X*uFYm&f|LV!>1SENY)W`fQH($17sW3Z9$NM%#byFW;jspg&58FUmznDMhI- z_#MMoIWuD5Yxyd?1<6GJ6MIxNp3h%wM^i^xVTCf`T-NnU)!*~D?57;}CvsvJF}VC9 z&cV@D-V3*Rijv0j^N#}&a-RIyT=k(lP?t&)>LLR0Dlp6z<}$6KeuTm+{^y8K%V>fB zHLS!=wE<OY4o{qJcO6+g3MRPk%ROEahxe)|jGt`bfCpp}tVpF(_Z-cK-m0{oQm<=PLc6 zBiM7h)9f#7~=r~)V^G^P4JD+r~P3+<1xJl$)l zx^=%QtNVnGU&m24ISI%tMpPn+ySsQ!YB~nx6)ORQ*^+IBX;F??Jb-huxX6)y{*@1$ z++!$1ZjmSc|M%?3|1>K9Zz-AoY@?-LH#R%!$|J)#;Yc<8<(Nja04&yR?q>K5M^2|f zcNZBY7U`35o8nb?&R70eN|-=QR4z0rY1{`{fS$lg_XHoK42Ky#{XJ(*uIZATgVEBD}U#`4_$&u*8guac~;q`_8Q?t>s=b1{^q#DIq_)pf3e-vW@B6{-hm zd~7D_rXg$YF28$j=N_E6G_G^1R%bgwK4>{6wsBwaJ;SgX^8L2IN2U!zQ56XxiOwW^ z*5Q5A%Od1n;B@Rnm}|J}eTlxF@+_U&3dmym{))WVZ^$c0FwmrAiZ9T|9|i z6%}&Lmy>nqWHXS5O8DJ`iv4)(DmJ2zg&c%-2-7S9kGZW3%ANVIoen1((Cb{`%}Y1B z`VfP9s)+>qPny{5t_?aDi(?X(ZM#&WVgAsSZKpQzcu7>n3mz7g)IK)#odo@L0s}e; zev{@Jak#gP$>?S-C@OErc+6XL6Jqo>aZ*rFGuw~V6?{{pK^N^wh}!r;Rju@{s8j~@ z<%z5D10>=d=P_Wm<8H2u___5_j62_DBQ+aTK3(3zdd1#I@fQBk<=47Y@bhTz_fa)?;p$}a(yE#S2gv9!?E{uc3Q1^bP;{Jb@@)+!7-xw zXJ@y(ZBFmcE#$VIrX3jTB^ats22RWed!Ok(MrjfHW1l~~Ej|pe0J~^etzxjEtKg^V zyfdd*7WcPvmoLz#<@)q7hh_*1WPg#0$!lgAsHe`6uQepw0d&NWQif_YOeAL8 zeRI!J_zQWeEXkY_U8h}sO#Kxqmn3QW=B|?zC9p|TU9QGHjSn`H52sV_?LM?=+7n~j zAmDIL%5!S!Xs7-4t9b@7P%@|pTpa?HuHi1MtJTHpSAVg13ig5_k>cuSW46|BR+GAYa+^4<+3LCGGlpl_49BmKjXV3KJZKGAlzq)lW;F3Cd zjv|aZY$3$>JN<)0donMHoYv)|@@OwWxAB8hUguu;*accBCtLm^J~2i%JKI+yZ7xXGyux zP%!1Tik~5y`ZoJv4_Wv|N=JeGhx}>&7xDb>6f}R>u2~1_ROdFPdM~Ip@|^9@1luC6 z=F$tgx*=+;8iurcdW7}<2K;;=laG2*&5hqG^`^@>EyauLJ+#i|j%qfgeALG8dd|Z7 zCW_vTzF)8-#S}73)XZt}I=UG9)-ML$nAkbwr@iKGgTdUT6j7G8N&XO;(*f;p9nz*N z+INSh>f#Q!Q!&OB7X6v=?y^@B1kGeOi<%0oKRRI7!9^kV{NwCCeE-Bc)WgJ^T<3=D ziBtPyCoWx~z!0}=%0nZ0FMWq>Ni(aWk`hh4nr-h{3{ywN&rP3Id*w+K$TtF6(AC(P zSd?`xpR!x__VpF~gdcIzP@)j)J=imUe?n6g$=tPUi`BtH{cc*VYECu>Ro%C^S|b!d5trkL>6_Y|@xjkm%Ki z3wnPj+4fHA;Zu8VMIB>X8yDCZ26WN6@qAmJV8>!oSS_AQ}dB7pCSKi)6 zqp_`T4>q2nJbQ>SiAf4O4S*@PT;}XovIj)+17yM-^WvRuek5=7|4JuSHcnKIj`xYP zd`o`#&YaAw@5{;*@a_u^!tsZB0Z;*e!hvq2@LLzb#MM0ba(}=|Ut=qfpyr(!_(r0D zt$%nnR%5ZFaD7@vUy^*%5b-0@HROZ)4+qrMg#pVKa>e#tJ$r)*jA$n@)r+_m0q``A z`>Peo0{Wf)6Q&*H@%;9JNdQ?nhq|_m;UXFRkgP~l`FXp9*_XUemD9}jNe;?bTKrx5 zGHiJgoQ$y2&(w}lvv{V_6EnX;DzzX(YR64tsn#AY-2|>zbrg#p^`*cK>6ZNn`sNDW z<0-k*K)XHt-a3;{F9-Xabmt9Cr;vf>&&|rz%k|$89j1YEzL|6&`6Co!4v^OGC;zDv z`=74oGgbO-cZU|4DK)vcXu-%i6sSSGD$aL`Jyb}Ilg?)4cPJx06H|4Ch4X2~h0~01 zLA9P=EgfloX&w?ZJwS{Y09{f+ucVTgUan?jYM-WcKa0BPKF4ST`MNE-dLZCUH_GiJ z|KH&sismPgcw$1AAKy6}mtTY++V%n~vh6%3iilIp@Fgbp1`Cb}3y8sVy;Hr-(dU6G0!_T-)-i7Yq5>dqdK?Le< zOX2pmz7zrZVB>&Ons8h}9;>W3v*k@4uX|o`b>y^C0y)QKsFv_Pw+8(f*w^bQ^y`_D zR+EqTbT{g{jb68GT2$B9wQ);i4<4XU=@+T&DGnbDy4tV|e4v)(tf$tDjM;|)FaJyM zZ(#Z1 zNwf0b(%4uQY7BEd>hk!@7$qf%cCp@P68l~xyWEZhT%c~%H@vUhE+0#~uj-8DXGKT7 z{bKKDf~o-HNz3k7{Hk6}Di#x#B9GwbfqVMhA`|JX3~lP~>4`?Fm@$w0ZTDnEE5tFD z?XQ0@Y@i6gdq8NU+I5%1Sp{#i?j-bXe>sKvVhb=Y>a9`L+L=%o_L-N5)YnT5#@KR0 zhe`9_Y8ulqzE_RC@zd7i7I<5rvA`$!|{p_!LPy5|gouK>BRnOi@*H<_pQKPxQnBss< z%>6#+`;W%wjm(mgp}v^#=a55dF}V;MWYQK+{R-nm%o>-tmlQ%^3stn2htOuLRXpYj zt%4o$eurZ9G)3uZ{?e|dz#xGA%KD0jgq5rgX`7g8gnW{LG6Ap`9wlZT6nvE!-`}QLH(y{&KtpFdxV-y@C z;@_d%2ekYuernp|-5vL)Jl70FQf$S1kzJ7m17fAMtWnjpBF(-~;(#5s5Iqu^r0&c* z84#{t^`6YDLrcsHakwN&I|x36^4iZ`6AYQ%k=L)cg<+x1Z5D-}6MHqysfq*a<5 z6mvd%Vu|_VdRwnvHhb0uOco6aS)PQ`nCbmgao!2c>*;kKEk}Jc^*iAj- zyc4k*)PpH$uUmPwEL6U-3sqp;;b?wsM*T`AtGI*>^C&jSxg=CnOe~YHg=WhZW-mxb zC;h69u`d%wNn85(L6rE`Ovi2^Eo!cE2DtU@Yk!Ug-dm=Z@CdZ7t26i~7f|b)&pKPS ze#&{1SimRUvllQK4^fZ>1{O}zNVF(ylF1cJ;Wg)id{LXii4aNNmnAje8P#TXUdCb1 zddS^3+u)+ka+Q=A46Wo_)N0>KXdHA)v5u7>UY%WN+6g_Itzc@Jzai+l_SlYHgb<=> zv`Wm@i44W7EJSPRUfP>K2 zfgaJ{o!55nI>o}SJ|S#$AM5vowlx-JJ&`@8ai2?X^6$?a3J$$#6TUQz#J8bzRV8bN z>dij8d5&vkHiLV?Mko z;Qn5Htsi>>)HPkhBABTvk7=4%R6%5;m}k6$y}xeZqg%(`;s;o4kBidMGrIWkI|Akt zB(dmQBQaBAa+Cd**Diq@o%y{v?>4z;^l(`J>|#&S4#ArKGwsbRA(rWWJ&p2)F<;vP z-x3cW&`Z)wXnR4wsA>aEV4d5luB4*J-M7K;!w)YnpaH>xe-s4R3+-vsl0>bQEgWRo zOL?LxC80Pc{(!9NxbV6*Wc@gj6zmC`^}K!gFynIZX>^`!*)U_Lg67J{~U2(RI7^Or}98)s;TAN$nu=DuZ)hiFwb zF)?#`u7+`eUC_BH-^CX_&X%yaZG?-PPZTl3U=dbZ71DX9_`J{TE;kOs%|zNdO|FB^ znW?jqzNQ4ZKNcr{I2ey69nV#R^S)I!Xd4?HvMh7I1)s9^WxNROFqw5doeO2n!uQ%c zkAG28EpKQ*^qRwfK_9Ql`!LS0d~P8_2LmT-FFIID;+co;_#~b&wKJi!9;@0mmT4l; zsbWZe(lTg^AlvbdfscqQtf%*VR+G;B$UDAT^;yQGmF@kH`3z0(U=20>{RAI<2S!5k z^SG(aJc_4m-nE%SQuuF=H?%s**l^tx;N_a$u}GRna;`(6( zsHXU0wz1ta>ynZHWG}O!4H?@#B%!1=)66yTj*s?4m zUD)(+`-12jwLyJ@{RCT!vbwxPv6r_&aT9dM^x*t8T1&nvbS;-YHeCDUqMvO8ZX?*{ z74)O2I3vfe^<}`p@5>~uFWj*t`8PyFviNfd^}L!il~j`GSi8kOxkxL$EzS$&39uaf z#OT=Ku^ZM`7<1_f{QHdbk%A9LJE0i8((Zf|lQW96fmGm=TN$IyNi##oJROr&s;cEN z_)76=k&9Dd%_A-v=VP`0-32 zY+QNpSMNM^#`;VKgvy3boENDN_GZmCmZ;Y0eBc^>H!pB1uE*vJcapDFNP>p+@_~JN z*1zg9d7w#lL!*3Pb22ZlUumeQT~N>Ai1jaL?--lGA4(N=gcs{GSvF->7Jc2l)&ya= zUN~9XqF7}C(ObLMblVc(<$ZCTX{EC$LV7Q}cvF5~C`_~2tkG6a_V3HD^19YFveud9=;c=k zm2UG$LOeNcq8E3syr23%ec+%_u_5v>L3j+?S?a#R*0Oz_r>>J%X#Tf1P5y0asX9ez zvX9|)6;n`%ETSaD9e&ia-W1?skIJSxUc#=YVPB9`r0Btcz#J6Y#!14wOsz+Y?-iMR?2Aw8hN zm2d1+Ki_6o9p@wCP(%ms614KaIb3p{rDp7<9MD`?LrFxxe_Hdf@CIh=z`d^+3{CxAG9l4hRucS+3e0 z&yf~0oq6^$s2Q_fIIb|u4IAw61ZC=bQz;F-2AB>siO1n#AFJH?`+62+vv?rR8pcD`RQQ%bC&O`731M&! zb=z7pU9K?A>}IFOHDT8~q#fJq>m@)?DSmC^tI~O`5j|l1=Ywls&4WiiiqjRl9m`GK zU``8(l^vE9M+sYeT6Z^~#o@i#&dO~Nol{=nVNiDyL(^g@njkR=-d6`Ia>&oL+X)?4 zhNf1}>ziHbDb`npeKNybVUwf0vAySbuvS|3P}~{WMnnBtnOV2q5vAb>;&7T+Y$q@e zl6_(ISTp!ITVP1DIVG=8P6Et*b+J$ZEMQ^M(3R2e#^x4!Wc8r$Gi!&tP3o#ce3I&- zb0#|72;6hGy$3jmnP=Ki-uL$wIlR8o*m1G38icb30ynpVco=EBc0h*QsKfw`Ep=@bL8qfQdH%ZPJHM{B-uEDAYW;;&Xk+Y~ z>x-FP%Yar{&~jzP=|L#6Hn5?BV7KgI}xnPp*vR zCp|L@P+YHLys`}JmY_>;c#1uhl_S^E!eF>qT94cs~d|@DeT@PVXBnbGgl7 zaX*W6z6|i$n_o(-vOLxEC~+>kWse$PS=~SagZYI)-P62Ry}yrm{9VBq&E+?6-y)cuk65Yv?wzhlVjT8*+*I`3tqNdm0>1 znaH{U=N{86rsJx2m3fV-XzAD`g*%?SCw@ni`Cao^!!c~k<`)nUh#LRYcbIw3`=eIc zC#P2Q6ON>2K%A?Y_TQi%14-frdZjsnM%{}n0jyMr*!@^9zk{yXWs&+`AT#1oRVEqqWY3AmA0{jygB2E01?u-0%!{wQan zj}Di(-H52LW>gLo$c*o0^vxBIaS|!3@0RMi*pTcA*`I)|6`!V(l6AR;u|%C9RrGps zrNy@+wi2mImp|0K4+{O040|cJUEh~g_lQ|liOE@{;03${atN?WNncxO{tKyb8ZZEA zjf*QAuvbMo0dLHFjM}MPl$@}YWQ4q`k1X$tH7E#ul_h15(R{3bGhzQoP~y4Y{8lQE zTpn!cW;^cHc)4>5JHUQldf!hXSjC+-Pl)>}Sr?Q&IMi-F%ADqspKlo(wgoB*jX@?Y zEZzzP=g2#2$%%%0fo*bb9o&-=zc*!yx{n9Tw-gFq6p^J)A^F9B=z;Z?FR;xF={t-s zq;KG8p>Oir5>Tx~OIT$@b$C3yC(RR~=*!pM3Dp1zOR<`%?Rr;Xv8-iAL`Bjt*WQm~ zfE5w@unJ_DQ3X}jZ*4U3NP6aY`z}&bgOt2f0Z9)qqW|ViTg?PR^(04qbmcnY20WTu zBY`AUSsHno7CV>)Pl4E94|;yBzRm#7T@5Vs3IJl&^|-h>_5y|gYh@sd9TM&V4CZv& za3tU1`w-ii2{*?KgYeX}pg$91GU~hyI$3J)Fnn)S#LOnoqbPT6QHkk#xl^t;XQ%h@ zvtz*18!<7{=W47yCcCPHT`lnUxcG@Fn@w*dU!yGr{)4`ebl!nbIpGk?(+Ybau!J^! zhh

-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 literal 0 HcmV?d00001 diff --git a/docs/regressions/issue-267/ifc43.ifc.png b/docs/regressions/issue-267/ifc43.ifc.png new file mode 100644 index 0000000000000000000000000000000000000000..7435403d5a9659434e8476723dda3fd4ea700d34 GIT binary patch literal 55486 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 { /* host-owned integration */ } + window.addEventListener('my-bim-action', handler, { signal }) + return () => window.removeEventListener('my-bim-action', handler) + } +}) +``` + +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)`, +`fitToModel()` and an idempotent asynchronous `unmount()`. + +Parsing runs in a dedicated module Worker. Closing/changing the file aborts imports, +terminates import workers, releases Fragments workers/models and disposes WebGL and +camera controls. Input size defaults to 512 MiB and the loading timeout to 120 seconds; +`maxFileBytes` and `loadTimeoutMs` allow smaller product-specific limits. These are +safety limits, not promises that every 512 MiB model is interactive on every device. + +## Scope and licenses + +The initial scope is visual inspection, not authoring, BCF, clash detection, +measurements or complete BIM semantics. IFC4 and IFC4.3 building samples are used +for the repeatable browser gate. Fonts, textures and references outside the IFC +are not fetched from remote services. Complex or unsupported geometry remains +subject to Web-IFC's capabilities; parse failures are explicit. + +That Open Components and Fragments are MIT. Web-IFC is MPL-2.0, isolated behind this +optional entry; the File Viewer adapter remains Apache-2.0. Preserve the copied +MPL license, corresponding-source notice and bundled legal notices when distributing +WASM/worker files. Fragments' upstream MIT text is retained in `licenses/` because +its published tarball does not contain its root license file. Source: +https://github.com/ThatOpen/engine_fragment/blob/main/LICENSE.md + +## Regression fixture attribution + +buildingSMART International Ltd., Certification-datasets, CC BY 4.0: +https://github.com/buildingSMART/Certification-datasets +Revision: `80d976a9b193a26a8e928c3e79bff67af1de68a8`. + +- `IFC 4.0.2.1 (IFC 4 ADD2 TC1)/Simple-Scene/Building-Architecture.ifc` +- `IFC 4.3.2.0 (IFC 4.3 ADD2)/Simple-Scene/Building-Architecture.ifc` + +The input files are unmodified. Test screenshots are rendered derivatives. +Fixture bytes are supplied separately; the browser script checks their SHA-256. +Run `pnpm --filter @file-viewer/renderer-3d verify:ifc-browser /path/to/fixtures` +with `ifc4.ifc` and `ifc43.ifc` in that directory after building the package. diff --git a/packages/renderers/3d/bin/copy-ifc-assets.mjs b/packages/renderers/3d/bin/copy-ifc-assets.mjs new file mode 100755 index 000000000..b565104c4 --- /dev/null +++ b/packages/renderers/3d/bin/copy-ifc-assets.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { realpathSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + mkdir, + copyFile, + readFile, + writeFile, + mkdtemp, + rm, + readdir, +} from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const require = createRequire(import.meta.url); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +async function packageDir(name, resolver = require) { + let current = dirname(resolver.resolve(name)); + for (;;) { + try { + const metadata = JSON.parse( + await readFile(join(current, "package.json"), "utf8"), + ); + if (metadata.name === name) return { path: current, metadata }; + } catch {} + const parent = dirname(current); + if (parent === current) + throw new Error(`Cannot locate ${name} package root`); + current = parent; + } +} +export async function copyIfcAssets(destination) { + const [webIfc, fragments, three] = await Promise.all( + ["web-ifc", "@thatopen/fragments", "three"].map((name) => packageDir(name)), + ); + const fragmentRequire = createRequire(join(fragments.path, "package.json")); + const packages = [ + webIfc, + fragments, + three, + ...(await Promise.all( + ["earcut", "flatbuffers", "lru-cache", "pako"].map((name) => + packageDir(name, fragmentRequire), + ), + )), + ]; + if ( + webIfc.metadata.version !== "0.0.77" || + fragments.metadata.version !== "3.4.7" + ) + throw new Error( + "IFC assets must match the tested web-ifc@0.0.77 and @thatopen/fragments@3.4.7 engines", + ); + const { build } = await import("esbuild"); + destination = resolve(destination); + await mkdir(dirname(destination), { recursive: true }); + const stage = await mkdtemp(join(dirname(destination), ".ifc-assets-")); + try { + await copyFile( + join(webIfc.path, "web-ifc.wasm"), + join(stage, "web-ifc.wasm"), + ); + await copyFile( + join(webIfc.path, "web-ifc-mt.wasm"), + join(stage, "web-ifc-mt.wasm"), + ); + await copyFile( + join(fragments.path, "dist/Worker/worker.mjs"), + join(stage, "fragments.worker.mjs"), + ); + await build({ + entryPoints: [join(packageRoot, "dist/ifc-import.worker.js")], + outfile: join(stage, "ifc-import.worker.js"), + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + legalComments: "linked", + minify: true, + logLevel: "silent", + }); + const licenses = join(stage, "licenses"); + await mkdir(licenses); + await copyFile( + join(packageRoot, "licenses/thatopen-fragments-MIT.txt"), + join(licenses, "thatopen-fragments-MIT.txt"), + ); + for (const pkg of packages) { + const names = (await readdir(pkg.path)).filter((name) => + /^licen[cs]e(?:[.-]|$)/i.test(name), + ); + for (const name of names) + await copyFile( + join(pkg.path, name), + join(licenses, `${pkg.metadata.name.replace(/[@/]/g, "_")}-${name}`), + ); + } + const notice = [ + "Optional IFC runtime assets for File Viewer.", + "These assets are not loaded by the ordinary model/Office entry.", + "web-ifc is MPL-2.0. Its WASM and bundled import code are unmodified upstream implementations.", + "Corresponding source and build instructions: https://github.com/ThatOpen/engine_web-ifc (release 0.0.77).", + "Fragments is MIT: https://github.com/ThatOpen/engine_fragment (release 3.4.7).", + "Preserve licenses/, the linked worker legal notices and this notice when redistributing.", + ...packages.map( + (pkg) => + `${pkg.metadata.name}@${pkg.metadata.version}: ${pkg.metadata.license || "See package license"}`, + ), + "", + ].join("\n"); + await writeFile(join(stage, "NOTICE.txt"), notice); + const files = {}; + for (const name of await readdir(stage)) { + if (name === "licenses") continue; + const bytes = await readFile(join(stage, name)); + files[name] = { + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + } + await writeFile( + join(stage, "manifest.json"), + JSON.stringify( + { + schema: 1, + packages: packages.map((pkg) => ({ + name: pkg.metadata.name, + version: pkg.metadata.version, + license: pkg.metadata.license, + })), + files, + }, + null, + 2, + ) + "\n", + ); + // Only replace our own asset names, never remove a user's destination directory. + await mkdir(join(destination, "licenses"), { recursive: true }); + for (const name of await readdir(stage)) { + if (name === "licenses") { + for (const license of await readdir(licenses)) + await copyFile( + join(licenses, license), + join(destination, "licenses", license), + ); + } else await copyFile(join(stage, name), join(destination, name)); + } + return { destination, files }; + } finally { + await rm(stage, { recursive: true, force: true }); + } +} +function isEntryPoint() { + try { + // npm/pnpm execute bin entries through a symlink; import.meta.url points + // at the real module. Do not silently skip the installed CLI in that case. + return Boolean(process.argv[1]) && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); + } catch { + // Importing this helper from eval/another program is not CLI execution. + return false; + } +} +if (isEntryPoint()) { + if (process.argv.includes("--help")) { + console.log("Usage: file-viewer-ifc-assets [destination-directory]"); + process.exit(0); + } + if (process.argv.length > 3 || process.argv[2]?.startsWith("-")) { + console.error("Expected one destination directory"); + process.exit(1); + } + copyIfcAssets(process.argv[2] || "public/file-viewer/vendor/ifc") + .then((result) => + console.log(`IFC assets installed: ${result.destination}`), + ) + .catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/packages/renderers/3d/licenses/thatopen-fragments-MIT.txt b/packages/renderers/3d/licenses/thatopen-fragments-MIT.txt new file mode 100644 index 000000000..c9b44cb8a --- /dev/null +++ b/packages/renderers/3d/licenses/thatopen-fragments-MIT.txt @@ -0,0 +1,18 @@ +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/renderers/3d/package.json b/packages/renderers/3d/package.json index d2fa021fb..e140e37b2 100644 --- a/packages/renderers/3d/package.json +++ b/packages/renderers/3d/package.json @@ -48,17 +48,27 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./ifc": { + "types": "./dist/ifc.d.ts", + "import": "./dist/ifc.js", + "default": "./dist/ifc.js" + } }, "files": [ "dist", "README.md", "README.en.md", - "LICENSE" + "LICENSE", + "bin", + "IFC.md", + "licenses" ], "scripts": { "build": "tsc -b tsconfig.json", - "type-check": "tsc -b tsconfig.json" + "type-check": "tsc -b tsconfig.json", + "verify:ifc": "node scripts/verify-ifc-entry.mjs", + "verify:ifc-browser": "node scripts/verify-ifc-browser.mjs" }, "dependencies": { "@file-viewer/core": "workspace:3.0.3", @@ -67,7 +77,34 @@ }, "devDependencies": { "@types/three": "^0.185.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "@thatopen/components": "3.4.8", + "@thatopen/fragments": "3.4.7", + "web-ifc": "0.0.77", + "esbuild": "^0.28.2" }, - "license": "Apache-2.0" + "license": "Apache-2.0", + "bin": { + "file-viewer-ifc-assets": "./bin/copy-ifc-assets.mjs" + }, + "peerDependencies": { + "@thatopen/components": "3.4.8", + "@thatopen/fragments": "3.4.7", + "web-ifc": "0.0.77", + "esbuild": "^0.28.2" + }, + "peerDependenciesMeta": { + "@thatopen/components": { + "optional": true + }, + "@thatopen/fragments": { + "optional": true + }, + "web-ifc": { + "optional": true + }, + "esbuild": { + "optional": true + } + } } diff --git a/packages/renderers/3d/scripts/download-ifc-fixtures.mjs b/packages/renderers/3d/scripts/download-ifc-fixtures.mjs new file mode 100644 index 000000000..6c1aa92d9 --- /dev/null +++ b/packages/renderers/3d/scripts/download-ifc-fixtures.mjs @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +const output = resolve(process.argv[2] || "output/ifc-samples"); +const revision = "80d976a9b193a26a8e928c3e79bff67af1de68a8"; +const files = [ + [ + "ifc4.ifc", + "IFC 4.0.2.1 (IFC 4 ADD2 TC1)/Simple-Scene/Building-Architecture.ifc", + "8790a1e193e82b8e7e7f337ec2633cd40f2120590317a1443503a25b079e2e80", + ], + [ + "ifc43.ifc", + "IFC 4.3.2.0 (IFC 4.3 ADD2)/Simple-Scene/Building-Architecture.ifc", + "96e7103812b15b65eb1fd7802d36b9962833485dce5ead4f440bbf529530881d", + ], +]; +await mkdir(output, { recursive: true }); +for (const [name, path, hash] of files) { + const response = await fetch( + `https://raw.githubusercontent.com/buildingSMART/Certification-datasets/${revision}/${path.split("/").map(encodeURIComponent).join("/")}`, + { signal: AbortSignal.timeout(60000) }, + ); + if (!response.ok) + throw new Error(`IFC fixture ${name}: HTTP ${response.status}`); + const parts = []; + let total = 0; + for await (const chunk of response.body) { + total += chunk.byteLength; + if (total > 8 * 1024 * 1024) throw new Error("Unexpectedly large fixture"); + parts.push(chunk); + } + const bytes = Buffer.concat(parts); + if (createHash("sha256").update(bytes).digest("hex") !== hash) + throw new Error(`IFC fixture checksum mismatch: ${name}`); + await writeFile(join(output, name), bytes); +} +await writeFile( + join(output, "NOTICE.txt"), + `Unmodified buildingSMART International Ltd. Certification-datasets fixtures, CC BY 4.0.\nhttps://github.com/buildingSMART/Certification-datasets/tree/${revision}\nLicense: https://creativecommons.org/licenses/by/4.0/\n`, +); +console.log(`Verified original IFC fixtures: ${output}`); diff --git a/packages/renderers/3d/scripts/verify-ifc-browser.mjs b/packages/renderers/3d/scripts/verify-ifc-browser.mjs new file mode 100644 index 000000000..1ef7024c7 --- /dev/null +++ b/packages/renderers/3d/scripts/verify-ifc-browser.mjs @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { createServer } from "node:http"; +import { resolve, dirname, extname, sep, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { copyIfcAssets } from "../bin/copy-ifc-assets.mjs"; +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../../.."); +const require = createRequire(resolve(root, "package.json")); +const { chromium } = require("playwright"); +const { build } = createRequire(resolve(here, "../package.json"))("esbuild"); +const samples = resolve(process.argv[2] || "output/ifc-samples"); +const output = resolve( + process.env.IFC_EVIDENCE_DIR || join(root, "output/ifc-browser"), +); +await mkdir(output, { recursive: true }); +const metadata = [ + [ + "ifc4.ifc", + "8790a1e193e82b8e7e7f337ec2633cd40f2120590317a1443503a25b079e2e80", + ], + [ + "ifc43.ifc", + "96e7103812b15b65eb1fd7802d36b9962833485dce5ead4f440bbf529530881d", + ], +]; +for (const [file, hash] of metadata) + assert.equal( + createHash("sha256") + .update(await readFile(join(samples, file))) + .digest("hex"), + hash, + file, + ); +await copyIfcAssets(join(output, "assets")); +// Static entry remains small; heavy optional engines must be in a separate lazy chunk. +const built = await build({ + entryPoints: [resolve(here, "../dist/ifc.js")], + outdir: join(output, "app"), + splitting: true, + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + metafile: true, + minify: true, + logLevel: "silent", +}); +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}`); +const content = `

`; +await writeFile(join(output, "index.html"), content); +const mime = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".wasm": "application/wasm", + ".json": "application/json", + ".ifc": "application/octet-stream", +}; +const server = createServer(async (req, res) => { + try { + if (req.url === "/favicon.ico") { + res.writeHead(204).end(); + return; + } + const pathname = decodeURIComponent( + new URL(req.url, "http://local").pathname, + ); + const file = pathname.startsWith("/sample/") + ? resolve(samples, pathname.slice(8)) + : resolve(output, "." + (pathname === "/" ? "/index.html" : pathname)); + if (!file.startsWith(samples + sep) && !file.startsWith(output + sep)) { + res.writeHead(403).end(); + return; + } + const bytes = await readFile(file); + res + .writeHead(200, { + "Content-Type": mime[extname(file)] || "application/octet-stream", + "Content-Security-Policy": + "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' data: blob:", + }) + .end(bytes); + } catch { + res.writeHead(404).end("Not found"); + } +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, + args: ["--enable-unsafe-swiftshader"], +}); +const report = { entryBytes: entry[1].bytes, cases: [] }; +try { + const page = await browser.newPage({ + viewport: { width: 1100, height: 820 }, + }); + page.setDefaultTimeout(120_000); + const errors = [], + requests = [], + consoleErrors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error("[browser]", msg.text()); + } + }); + page.on("request", (req) => + requests.push({ method: req.method(), url: req.url() }), + ); + await page.addInitScript(() => { + const Original = window.Worker; + window.workerCounts = { created: 0, active: 0 }; + window.Worker = class extends Original { + constructor(...args) { + super(...args); + workerCounts.created++; + workerCounts.active++; + this.stopped = false; + } + terminate() { + if (!this.stopped) { + this.stopped = true; + workerCounts.active--; + } + return super.terminate(); + } + }; + }); + await page.goto(origin); + await page.waitForFunction(() => window.entryReady); + assert.equal(await page.evaluate(() => workerCounts.created), 0); + assert.ok( + !requests.some( + (r) => r.url.includes("ifcRuntime") || r.url.includes(".wasm"), + ), + "Heavy entry requested eagerly", + ); + for (const [name] of metadata) { + const start = Date.now(); + console.log("Loading", name); + const loaded = await page.evaluate((name) => openIfc(name), name); + assert.ok(loaded.count > 0, JSON.stringify(loaded)); + await page.waitForSelector('[data-ifc-status="ready"]'); + const selected = await page.evaluate(async () => { + const selection = await instance.select( + Number(instance.$el.dataset.ifcFirstElement), + ); + return { + localId: selection.localId, + name: selection.name, + globalId: selection.globalId, + entityType: selection.entityType, + }; + }); + assert.ok( + selected.globalId && selected.entityType, + JSON.stringify(selected), + ); + await page.getByRole("button", { name: "Clear selection" }).click(); + await page.waitForFunction( + () => + document.querySelector("[data-ifc-selected]")?.dataset.ifcSelected === + "", + ); + // Find a real ray hit without assuming the file's element IDs or geometry. + const hit = await page.evaluate(async () => { + const { world, model } = extension, + canvas = world.renderer.three.domElement, + r = canvas.getBoundingClientRect(); + for (const fy of [0.5, 0.4, 0.6, 0.3, 0.7]) + for (const fx of [0.5, 0.4, 0.6, 0.3, 0.7]) { + const x = r.left + r.width * fx, + y = r.top + r.height * fy; + const hit = await model.raycast({ + camera: world.camera.three, + mouse: world.renderer.getSize().clone().set(x, y), + dom: canvas, + }); + if (hit) return { x, y, localId: hit.localId }; + } + return null; + }); + assert.ok(hit, "No visible IFC geometry can be picked"); + await page.mouse.click(hit.x, hit.y); + await page.waitForFunction( + (id) => + document.querySelector("[data-ifc-selected]")?.dataset.ifcSelected === + String(id), + hit.localId, + ); + 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, + })); + assert.ok(drawn.calls > 0 && drawn.triangles > 0, JSON.stringify(drawn)); + await page.screenshot({ path: join(output, `${name}.png`) }); + await page.getByRole("button", { name: "Fit model" }).click(); + await page.evaluate(async () => { + await instance.unmount(); + await instance.unmount(); + }); + await page.waitForFunction(() => workerCounts.active === 0); + assert.equal(await page.locator("canvas").count(), 0); + assert.equal(await page.evaluate(() => cleanupCount), 1); + report.cases.push({ + name, + ...loaded, + selected, + picked: hit.localId, + drawn, + milliseconds: Date.now() - start, + workers: await page.evaluate(() => ({ ...workerCounts })), + }); + console.log("Passed", name, JSON.stringify(report.cases.at(-1))); + } + // Input limit is enforced before another worker or transferable copy is allocated. + const limit = await page.evaluate(async () => { + const n = workerCounts.created; + try { + await openIfc("ifc4.ifc", { maxFileBytes: 1 }); + return false; + } catch (e) { + return /size limit/.test(e.message) && workerCounts.created === n; + } + }); + assert.ok(limit); + const aborted = await page.evaluate(async () => { + const pending = openIfc("ifc4.ifc"); + while (!document.querySelector('[data-ifc-status="loading"]')) + await new Promise((r) => setTimeout(r, 1)); + controller.abort(); + try { + await pending; + return false; + } catch (e) { + return e.name === "AbortError"; + } + }); + assert.ok(aborted); + await page.waitForFunction(() => workerCounts.active === 0); + assert.equal(await page.locator("canvas").count(), 0); + assert.deepEqual(errors, []); + assert.deepEqual(consoleErrors, []); + assert.ok( + requests.every((r) => r.url.startsWith(origin + "/") && r.method === "GET"), + "External request or file upload", + ); + report.requests = requests; + report.errors = errors; + report.consoleErrors = consoleErrors; + report.inputLimit = true; + report.abort = true; +} catch (error) { + console.error(error); + await Promise.all( + browser + .contexts() + .flatMap((c) => + c + .pages() + .map((p) => + p.screenshot({ path: join(output, "failure.png") }).catch(() => {}), + ), + ), + ); + throw error; +} finally { + await writeFile(join(output, "report.json"), JSON.stringify(report, null, 2)); + await browser.close(); + await new Promise((resolve) => server.close(resolve)); +} +console.log("Optional IFC original-file browser checks passed."); diff --git a/packages/renderers/3d/scripts/verify-ifc-entry.mjs b/packages/renderers/3d/scripts/verify-ifc-entry.mjs new file mode 100644 index 000000000..6b58fe7e7 --- /dev/null +++ b/packages/renderers/3d/scripts/verify-ifc-entry.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile, mkdtemp, symlink, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createRendererRegistry, + installFileViewerRendererPlugins, +} from "@file-viewer/core"; +import { createIfcRenderer } from "../dist/ifc.js"; +const registry = createRendererRegistry(); +const handlers = []; +await installFileViewerRendererPlugins({ + registry, + plugins: [createIfcRenderer()], + registerHandler: (item) => handlers.push(item), +}); +assert.equal(registry.getByExtension("ifc")?.id, "ifc"); +assert.equal(registry.getByExtension("glb")?.id, "model"); +assert.ok(handlers.some((item) => item.rendererId === "ifc")); +const entry = await readFile( + new URL("../dist/ifc.js", import.meta.url), + "utf8", +); +assert.ok(!/from\s+['"](?:three|@thatopen|web-ifc)/.test(entry)); +const main = await readFile( + new URL("../src/index.ts", import.meta.url), + "utf8", +); +assert.ok( + !main.includes("./ifc"), + "Optional IFC must not enter the ordinary entry", +); +const pkg = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), +); +for (const name of [ + "@thatopen/components", + "@thatopen/fragments", + "web-ifc", + "esbuild", +]) { + assert.ok(!pkg.dependencies[name]); + assert.equal(pkg.peerDependenciesMeta[name]?.optional, true); +} +assert.equal(pkg.exports["./ifc"].import, "./dist/ifc.js"); +console.log( + "IFC enhancement ownership, optional dependencies and lazy entry passed.", +); + +const cli = fileURLToPath(new URL("../bin/copy-ifc-assets.mjs", import.meta.url)); +const temp = await mkdtemp(join(tmpdir(), "file-viewer-ifc-cli-")); +try { + const alias = join(temp, "file-viewer-ifc-assets"); + await symlink(cli, alias); + for (const command of [cli, alias]) { + const help = spawnSync(process.execPath, [command, "--help"], { encoding: "utf8" }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /Usage: file-viewer-ifc-assets/); + const invalid = spawnSync(process.execPath, [command, "--unknown"], { encoding: "utf8" }); + assert.equal(invalid.status, 1); + assert.match(invalid.stderr, /Expected one destination directory/); + } + const moduleUrl = new URL("../bin/copy-ifc-assets.mjs", import.meta.url).href; + const imported = spawnSync(process.execPath, ["--input-type=module", "-e", + `import {copyIfcAssets} from ${JSON.stringify(moduleUrl)}; if (typeof copyIfcAssets !== 'function') process.exit(1)`, + join(temp, "nonexistent-entry")], { encoding: "utf8" }); + assert.equal(imported.status, 0, imported.stderr); + assert.equal(imported.stdout, ""); +} finally { + await rm(temp, { recursive: true, force: true }); +} +console.log("Installed CLI symlink execution and side-effect-free helper import passed."); diff --git a/packages/renderers/3d/src/ifc-import.worker.ts b/packages/renderers/3d/src/ifc-import.worker.ts new file mode 100644 index 000000000..2d00e2084 --- /dev/null +++ b/packages/renderers/3d/src/ifc-import.worker.ts @@ -0,0 +1,34 @@ +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) + | null; + postMessage(message: unknown, transfer?: Transferable[]): void; +}; +let active = false; +scope.onmessage = async ({ data }) => { + if (active) return; + active = true; + try { + const importer = new IfcImporter(); + importer.wasm = { path: data.wasmPath, absolute: true }; + importer.webIfcSettings = { COORDINATE_TO_ORIGIN: true }; + importer.includeUniqueAttributes = true; + importer.includeRelationNames = true; + const result = await importer.process({ + bytes: new Uint8Array(data.bytes), + progressCallback: (progress) => + scope.postMessage({ kind: "progress", progress }), + }); + // slice owns the exact byte range even when the importer returns a view. + const bytes = Uint8Array.from(result).buffer; + scope.postMessage({ kind: "ready", bytes }, [bytes]); + } catch (error) { + scope.postMessage({ + kind: "error", + message: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/packages/renderers/3d/src/ifc.ts b/packages/renderers/3d/src/ifc.ts new file mode 100644 index 000000000..7abf16606 --- /dev/null +++ b/packages/renderers/3d/src/ifc.ts @@ -0,0 +1,87 @@ +import type { + FileRenderContext, + FileViewerRendererPlugin, + FileRenderHandler, + RendererDefinition, +} from "@file-viewer/core"; +import type * as OBC from "@thatopen/components"; +import type * as FRAGS from "@thatopen/fragments"; + +export interface IfcSelection { + localId: number; + name: string; + globalId: string; + entityType: string; + attributes: FRAGS.ItemData; +} +export interface IfcExtensionContext { + components: OBC.Components; + fragments: FRAGS.FragmentsModels; + world: OBC.SimpleWorld; + model: FRAGS.FragmentsModel; + signal: AbortSignal; + select: (localId: number | null) => Promise; +} +export interface IfcViewerOptions { + /** Directory installed by file-viewer-ifc-assets. Defaults to /file-viewer/vendor/ifc/. */ + assetBaseUrl?: string | URL; + fitToModel?: boolean; + enableSelection?: boolean; + showProperties?: boolean; + /** Whole-input limit, before a transferable copy is made. Default: 512 MiB. */ + maxFileBytes?: number; + /** Bound stalled import/worker setup. Default: 120 seconds. */ + loadTimeoutMs?: number; + onSelectionChange?: (selection: IfcSelection | null) => void; + /** Advanced, opt-in access. Return cleanup for resources created by the hook. */ + configure?: ( + context: IfcExtensionContext, + ) => void | (() => void) | Promise void)>; +} +export interface IfcViewerInstance { + $el: HTMLElement; + unmount(): Promise; + fitToModel(): Promise; + select(localId: number | null): Promise; +} +export const ifcRendererDefinition: RendererDefinition = { + id: "ifc", + label: "IFC BIM", + category: "model", + extensions: [], + packageName: "@file-viewer/renderer-3d", + supportLevel: "structured", + status: "experimental", + enhancesRendererId: "model", + enhancesExtensions: ["ifc"], +}; +export function createIfcRenderer( + options: IfcViewerOptions = {}, +): FileViewerRendererPlugin< + FileRenderHandler +> { + return { + id: "file-viewer-renderer-ifc", + label: "Flyfish optional IFC viewer", + definitions: [ifcRendererDefinition], + handlers: [ + { + rendererId: "ifc", + handler: (buffer, target, _type, context) => + renderFileViewerIfc(buffer, target, context, options), + }, + ], + }; +} +export async function renderFileViewerIfc( + buffer: ArrayBuffer, + target: HTMLDivElement, + context?: FileRenderContext, + options: IfcViewerOptions = {}, +): Promise { + // Optional engines never enter the normal model/Office entry or initial bundle. + const { renderIfc } = await import("./ifcRuntime.js"); + return renderIfc(buffer, target, context, options); +} +export const ifcRenderer = createIfcRenderer(); +export default ifcRenderer; diff --git a/packages/renderers/3d/src/ifcRuntime.ts b/packages/renderers/3d/src/ifcRuntime.ts new file mode 100644 index 000000000..7dcdd1b49 --- /dev/null +++ b/packages/renderers/3d/src/ifcRuntime.ts @@ -0,0 +1,484 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as FRAGS from "@thatopen/fragments"; +import type { FileRenderContext } from "@file-viewer/core"; +import type { + IfcSelection, + IfcViewerInstance, + IfcViewerOptions, +} from "./ifc.js"; + +const css = `.fv-ifc{position:relative;display:flex;flex-direction:column;height:100%;min-height:240px;background:#f5f7fa;color:#182333;font:13px/1.5 system-ui,sans-serif}.fv-ifc *{box-sizing:border-box}.fv-ifc-toolbar{display:flex;gap:8px;align-items:center;padding:9px 12px;border-bottom:1px solid #dbe2e9;background:#fff}.fv-ifc button{font:inherit;padding:5px 10px;background:#fff;color:inherit;border:1px solid #cbd5e1;border-radius:5px;cursor:pointer}.fv-ifc button:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.fv-ifc-toolbar span{margin-left:auto}.fv-ifc-body{display:flex;position:relative;flex:1;min-height:0}.fv-ifc-stage{position:relative;flex:1;min-width:0;min-height:200px;overflow:hidden}.fv-ifc-stage canvas{display:block;width:100%;height:100%}.fv-ifc-properties{width:260px;max-width:45%;padding:12px;overflow:auto;border-left:1px solid #dbe2e9;background:#fff;overflow-wrap:anywhere}.fv-ifc-properties[hidden]{display:none}.fv-ifc-properties h3{margin:0 0 12px}.fv-ifc-properties dt{font-weight:600;margin-top:9px}.fv-ifc-properties dd{margin:1px 0;color:#475569;white-space:pre-wrap}.fv-ifc-status{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;background:#f5f7fa;white-space:pre-wrap}.fv-ifc-status[hidden]{display:none}@media(max-width:560px){.fv-ifc-properties{width:180px;max-width:45%}}`; +const abortError = () => + new DOMException("IFC preview cancelled", "AbortError"); +const attribute = (data: FRAGS.ItemData, ...names: string[]) => { + for (const name of names) { + const value = data[name]; + if ( + value && + !Array.isArray(value) && + "value" in value && + value.value != null + ) + return String(value.value); + } + return ""; +}; +export async function renderIfc( + buffer: ArrayBuffer, + target: HTMLDivElement, + context: FileRenderContext | undefined, + options: IfcViewerOptions, +): Promise { + if (context?.signal?.aborted) throw abortError(); + const maxBytes = options.maxFileBytes ?? 512 * 1024 * 1024; + const timeoutMs = options.loadTimeoutMs ?? 120_000; + if ( + !Number.isFinite(maxBytes) || + maxBytes <= 0 || + buffer.byteLength > maxBytes + ) + throw new Error("IFC input exceeds the configured size limit"); + if (!Number.isFinite(timeoutMs) || timeoutMs < 1) + throw new Error("Invalid IFC load timeout"); + const header = new TextDecoder().decode(buffer.slice(0, 65536)); + if ( + !/^\s*ISO-10303-21\s*;/i.test(header.replace(/^\uFEFF/, "")) || + !/FILE_SCHEMA\s*\(\s*\(\s*['"]IFC/i.test(header) + ) + throw new Error("The file is not an IFC STEP document"); + if (typeof Worker === "undefined") + throw new Error("IFC preview requires Web Workers"); + const assetBase = new URL( + String(options.assetBaseUrl ?? "/file-viewer/vendor/ifc/"), + target.ownerDocument.baseURI, + ); + if (!/^https?:$/.test(assetBase.protocol)) + throw new Error("IFC assets must use a local HTTP(S) asset directory"); + if (!assetBase.pathname.endsWith("/")) assetBase.pathname += "/"; + const cn = String(context?.options?.locale || "").startsWith("zh"); + const jp = String(context?.options?.locale || "").startsWith("ja"); + const words = cn + ? [ + "适合窗口", + "清除选择", + "属性", + "正在解析 IFC…", + "点击构件查看属性", + "构件", + ] + : jp + ? [ + "全体表示", + "選択解除", + "プロパティ", + "IFC を読み込み中…", + "要素を選択してプロパティを表示", + "要素", + ] + : [ + "Fit model", + "Clear selection", + "Properties", + "Loading IFC…", + "Select an element to inspect its properties", + "elements", + ]; + const doc = target.ownerDocument; + const root = doc.createElement("div"); + root.className = "fv-ifc"; + root.dataset.ifcStatus = "loading"; + const style = doc.createElement("style"); + style.textContent = css; + const toolbar = doc.createElement("div"); + toolbar.className = "fv-ifc-toolbar"; + const button = (text: string) => { + const value = doc.createElement("button"); + value.type = "button"; + value.textContent = text; + return value; + }; + const fitButton = button(words[0]); + const clearButton = button(words[1]); + const propertiesButton = button(words[2]); + const summary = doc.createElement("span"); + summary.setAttribute("aria-live", "polite"); + toolbar.append(fitButton, clearButton, propertiesButton, summary); + const body = doc.createElement("div"); + body.className = "fv-ifc-body"; + const stage = doc.createElement("div"); + stage.className = "fv-ifc-stage"; + const status = doc.createElement("div"); + status.className = "fv-ifc-status"; + status.setAttribute("role", "status"); + status.textContent = words[3]; + const properties = doc.createElement("aside"); + properties.className = "fv-ifc-properties"; + properties.hidden = options.showProperties === false; + properties.textContent = words[4]; + propertiesButton.setAttribute("aria-expanded", String(!properties.hidden)); + stage.append(status); + body.append(stage, properties); + root.append(toolbar, body); + target.replaceChildren(style, root); + + const controller = new AbortController(); + let disposed = false; + let importWorker: Worker | undefined; + let components: OBC.Components | undefined; + let fragments: FRAGS.FragmentsModels | undefined; + let world: + | OBC.SimpleWorld + | undefined; + let model: FRAGS.FragmentsModel | undefined; + let extensionCleanup: void | (() => void); + let ready = false; + let selectionId = 0; + let selectionQueue: Promise = Promise.resolve(); + let updatePending = false; + const id = `ifc-${crypto.randomUUID()}`; + let timeout: ReturnType | undefined; + let removeCanvasEvents = () => {}; + let removeControlEvents = () => {}; + let disposePromise: Promise | undefined; + const ensureLive = () => { + if (disposed || controller.signal.aborted) + throw controller.signal.reason ?? abortError(); + }; + const showError = (error: unknown) => { + if (disposed) return; + root.dataset.ifcStatus = "error"; + status.hidden = false; + status.textContent = error instanceof Error ? error.message : String(error); + }; + const update = async () => { + if (disposed || !fragments || updatePending) return; + updatePending = true; + try { + await fragments.update(); + } catch (error) { + showError(error); + } finally { + updatePending = false; + } + }; + 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 cleanup = extensionCleanup; + extensionCleanup = undefined; + root.remove(); + style.remove(); + disposePromise = (async () => { + try { + cleanup?.(); + } finally { + // Stop the frame loop before releasing worker-owned model geometry. + if (currentComponents) currentComponents.enabled = false; + try { + if (currentFragments) { + currentFragments.abort(id); + await currentFragments.dispose(); + } + } finally { + currentComponents?.dispose(); + } + } + })(); + return disposePromise; + }; + const onAbort = () => { + void unmount().catch(() => {}); + }; + context?.signal?.addEventListener("abort", onAbort, { once: true }); + const withCancellation = (promise: Promise): Promise => + new Promise((resolve, reject) => { + const abort = () => reject(controller.signal.reason ?? abortError()); + if (controller.signal.aborted) { + abort(); + return; + } + controller.signal.addEventListener("abort", abort, { once: true }); + promise + .then(resolve, reject) + .finally(() => controller.signal.removeEventListener("abort", abort)); + }); + const fitToModel = async () => { + ensureLive(); + if (!world || !model) return; + const box = model.box.clone(); + if (box.isEmpty()) + throw new Error("IFC model contains no renderable geometry"); + const center = box.getCenter(new THREE.Vector3()); + const size = Math.max(box.getSize(new THREE.Vector3()).length(), 1); + world.camera.three.near = Math.max(0.01, size / 10000); + world.camera.three.far = Math.max(1000, size * 100); + world.camera.three.updateProjectionMatrix(); + await world.camera.controls.setLookAt( + center.x + size, + center.y + size * 0.8, + center.z + size, + center.x, + center.y, + center.z, + false, + ); + await world.camera.controls.fitToBox(box, false); + await fragments?.update(true); + }; + const renderProperties = (selection: IfcSelection | null) => { + properties.replaceChildren(); + if (!selection) { + properties.textContent = words[4]; + return; + } + const title = doc.createElement("h3"); + title.textContent = + selection.name || selection.entityType || String(selection.localId); + properties.append(title); + const list = doc.createElement("dl"); + let count = 0; + const append = (label: string, value: unknown) => { + if (++count > 300) return; + const key = doc.createElement("dt"); + key.textContent = label.slice(0, 200); + const item = doc.createElement("dd"); + item.textContent = String(value ?? "").slice(0, 2048); + list.append(key, item); + }; + append("IFC type", selection.entityType); + append("GlobalId", selection.globalId); + append("Local ID", selection.localId); + const visit = (data: FRAGS.ItemData, prefix = "", depth = 0) => { + if (depth > 3 || count >= 300) return; + for (const [key, value] of Object.entries(data)) { + if (count >= 300) break; + if (Array.isArray(value)) { + for (const child of value.slice(0, 30)) + visit(child, `${prefix}${key} / `, depth + 1); + } else if (value && "value" in value) append(prefix + key, value.value); + } + }; + visit(selection.attributes); + properties.append(list); + }; + const select = (localId: number | null): Promise => { + const request = ++selectionId; + const next = selectionQueue + .catch(() => {}) + .then(async () => { + ensureLive(); + if (!model || !ready || request !== selectionId) return null; + if (localId !== null && (!Number.isSafeInteger(localId) || localId < 0)) + throw new Error("Invalid IFC local ID"); + let selection: IfcSelection | null = null; + if (localId !== null) { + const [data] = await model.getItemsData([localId], { + attributesDefault: true, + relations: { + IsDefinedBy: { attributes: true, relations: true }, + DefinesOccurrence: { attributes: false, relations: false }, + }, + }); + ensureLive(); + if (request !== selectionId) return null; + if (!data) throw new Error("IFC item not found"); + selection = { + localId, + name: attribute(data, "Name"), + globalId: attribute(data, "GlobalId", "_guid"), + entityType: attribute(data, "_category", "type"), + attributes: data, + }; + } + await model.resetHighlight(); + if (selection) + await model.highlight([selection.localId], { + color: new THREE.Color("#3b82f6"), + renderedFaces: FRAGS.RenderedFaces.TWO, + opacity: 1, + transparent: false, + }); + ensureLive(); + if (request !== selectionId) return null; + root.dataset.ifcSelected = selection ? String(selection.localId) : ""; + renderProperties(selection); + await fragments?.update(true); + options.onSelectionChange?.(selection); + return selection; + }); + selectionQueue = next; + return next; + }; + fitButton.addEventListener("click", () => { + void fitToModel().catch(showError); + }); + clearButton.addEventListener("click", () => { + void select(null).catch(showError); + }); + propertiesButton.addEventListener("click", () => { + properties.hidden = !properties.hidden; + propertiesButton.setAttribute("aria-expanded", String(!properties.hidden)); + }); + fitButton.disabled = clearButton.disabled = true; + try { + const importResult = new Promise((resolve, reject) => { + importWorker = new Worker(new URL("ifc-import.worker.js", assetBase), { + type: "module", + name: "file-viewer-ifc-import", + }); + importWorker.onmessage = ({ data }) => { + if (data?.kind === "progress") + summary.textContent = `${Math.round(Math.max(0, Math.min(1, Number(data.progress) || 0)) * 100)}%`; + else if (data?.kind === "ready" && data.bytes instanceof ArrayBuffer) + resolve(new Uint8Array(data.bytes)); + else if (data?.kind === "error") + reject(new Error(`IFC import failed: ${String(data.message)}`)); + }; + importWorker.onerror = (event) => + reject( + new Error( + `IFC worker failed. Install the self-hosted assets at ${assetBase.href}: ${event.message}`, + ), + ); + importWorker.onmessageerror = () => + reject(new Error("Invalid IFC worker response")); + const copy = buffer.slice(0); + importWorker.postMessage({ bytes: copy, wasmPath: assetBase.href }, [ + copy, + ]); + }); + timeout = setTimeout(() => { + controller.abort(new Error("IFC loading timed out")); + onAbort(); + }, timeoutMs); + const bytes = await withCancellation(importResult); + ensureLive(); + importWorker?.terminate(); + importWorker = undefined; + components = new OBC.Components(); + world = components + .get(OBC.Worlds) + .create(); + world.scene = new OBC.SimpleScene(components); + world.renderer = new OBC.SimpleRenderer(components, stage, { + antialias: true, + alpha: false, + }); + world.renderer.showLogo = false; + world.renderer.three.setPixelRatio( + Math.min(target.ownerDocument.defaultView?.devicePixelRatio || 1, 2), + ); + world.camera = new OBC.SimpleCamera(components); + world.scene.setup({ backgroundColor: new THREE.Color("#f5f7fa") }); + components.init(); + fragments = new FRAGS.FragmentsModels( + new URL("fragments.worker.mjs", assetBase).href, + { maxWorkers: 2 }, + ); + model = await withCancellation(fragments.load(bytes, { modelId: id })); + ensureLive(); + model.useCamera(world.camera.three); + world.scene.three.add(model.object); + world.camera.controls.addEventListener("update", update); + removeControlEvents = () => + world?.camera.controls.removeEventListener("update", update); + const ids = await withCancellation(model.getItemsIdsWithGeometry()); + ensureLive(); + if (!ids.length) + throw new Error("IFC model contains no renderable geometry"); + root.dataset.ifcElementCount = String(ids.length); + root.dataset.ifcFirstElement = String(ids[0]); + summary.textContent = `${ids.length} ${words[5]}`; + if (options.fitToModel !== false) await withCancellation(fitToModel()); + ready = true; + const canvas = world.renderer.three.domElement; + let start: { x: number; y: number } | undefined; + const down = (event: PointerEvent) => { + if (event.button === 0) start = { x: event.clientX, y: event.clientY }; + }; + const up = (event: PointerEvent) => { + const original = start; + start = undefined; + if ( + !original || + options.enableSelection === false || + Math.hypot(event.clientX - original.x, event.clientY - original.y) > + 5 || + !model || + !world + ) + return; + // Fragment raycasting takes client-pixel coordinates, not normalized NDC. + void model + .raycast({ + camera: world.camera.three, + mouse: new THREE.Vector2(event.clientX, event.clientY), + dom: canvas, + }) + .then((hit) => { + if (!disposed) return select(hit?.localId ?? null); + }) + .catch(showError); + }; + canvas.addEventListener("pointerdown", down); + canvas.addEventListener("pointerup", up); + removeCanvasEvents = () => { + canvas.removeEventListener("pointerdown", down); + canvas.removeEventListener("pointerup", up); + }; + if (options.configure) { + const pending = Promise.resolve( + options.configure({ + components, + fragments, + world, + 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"; + status.hidden = true; + fitButton.disabled = false; + clearButton.disabled = options.enableSelection === false; + context?.onProgressiveRender?.(); + return { $el: root, unmount, fitToModel, select }; + } catch (error) { + const cancelled = context?.signal?.aborted || !target.contains(root); + const message = error instanceof Error ? error.message : String(error); + await unmount().catch(() => {}); + if (!cancelled) { + root.dataset.ifcStatus = "error"; + status.textContent = message; + status.hidden = false; + // Retain a helpful error surface without retaining any workers or WebGL resources. + stage.replaceChildren(status); + target.replaceChildren(style, root); + } + throw error; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95bee8426..2bf020091 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@thatopen/components>jszip': 3.10.2 adm-zip@<0.6.0: 0.6.0 acorn@>=7.0.0 <7.1.1: 7.4.1 js-yaml@>=4.0.0 <4.3.2: 4.3.2 @@ -1286,12 +1287,24 @@ importers: specifier: ^0.185.1 version: 0.185.1 devDependencies: + '@thatopen/components': + specifier: 3.4.8 + version: 3.4.8(@thatopen/fragments@3.4.7(three@0.185.1)(web-ifc@0.0.77))(camera-controls@3.1.2(three@0.185.1))(three@0.185.1)(web-ifc@0.0.77) + '@thatopen/fragments': + specifier: 3.4.7 + version: 3.4.7(three@0.185.1)(web-ifc@0.0.77) '@types/three': specifier: ^0.185.4 version: 0.185.4 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 typescript: specifier: ^6.0.3 version: 6.0.3 + web-ifc: + specifier: 0.0.77 + version: 0.0.77 packages/renderers/archive: dependencies: @@ -3053,6 +3066,9 @@ packages: cpu: [x64] os: [win32] + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -3442,6 +3458,21 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@thatopen/components@3.4.8': + resolution: {integrity: sha512-eil8zla7W45WI1Pf/xAcY4oQpmb7pDJnyN9jWP2rkuboGIol+RyGCo9XHR2GnL9Ckgb+Jd595oXhyiotQ8lVAA==} + peerDependencies: + '@thatopen/fragments': ~3.4.7 + camera-controls: '>=3.1.2' + three: '>=0.182.0' + web-ifc: '>=0.0.77' + + '@thatopen/fragments@3.4.7': + resolution: {integrity: sha512-tGuS7LVf0BkN5qQISG3VAGeMyqiZYwkvcHGYZ+eA5emr+v8aIjEKZvM/eYrGhJKUv/eZ+N5qeevEftfcmoBc+g==} + engines: {node: '>=20.11.0'} + peerDependencies: + three: '>=0.182.0' + web-ifc: '>=0.0.77' + '@tmcw/togeojson@7.1.2': resolution: {integrity: sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A==} @@ -4072,6 +4103,12 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} @@ -4712,6 +4749,10 @@ packages: resolution: {integrity: sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==} hasBin: true + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + fastdom@1.0.12: resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} @@ -4730,6 +4771,9 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + flatbuffers@25.2.10: + resolution: {integrity: sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw==} + flexsearch@0.8.212: resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} @@ -5426,6 +5470,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -6385,6 +6433,11 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + three-mesh-bvh@0.9.9: + resolution: {integrity: sha512-FJKitcjvbALmeQRK+Sc+nLGorCpkrZBrbgJZFzhdyWboak37DZikn46hvQkNqSbJPm227ahYmS6k3N/GXaAyXw==} + peerDependencies: + three: '>= 0.159.0' + three@0.185.1: resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} @@ -6723,6 +6776,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-ifc@0.0.77: + resolution: {integrity: sha512-VzQ0W/Iiqbidxn1ECUvz6qJ6p2sXBVNcOsUOBCETzy77psAH6yFLKQm74aXabkx3JH4OvFVHe8k1qS6+Z2zl1w==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -7641,6 +7697,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.3': optional: true + '@nodable/entities@2.2.0': {} + '@nodable/entities@3.0.0': {} '@oozcitak/dom@2.0.2': @@ -7933,6 +7991,25 @@ snapshots: postcss: 8.5.23 tailwindcss: 4.3.3 + '@thatopen/components@3.4.8(@thatopen/fragments@3.4.7(three@0.185.1)(web-ifc@0.0.77))(camera-controls@3.1.2(three@0.185.1))(three@0.185.1)(web-ifc@0.0.77)': + dependencies: + '@thatopen/fragments': 3.4.7(three@0.185.1)(web-ifc@0.0.77) + camera-controls: 3.1.2(three@0.185.1) + fast-xml-parser: 5.7.2 + jszip: 3.10.2 + three: 0.185.1 + three-mesh-bvh: 0.9.9(three@0.185.1) + web-ifc: 0.0.77 + + '@thatopen/fragments@3.4.7(three@0.185.1)(web-ifc@0.0.77)': + dependencies: + earcut: 3.2.3 + flatbuffers: 25.2.10 + lru-cache: 11.1.0 + pako: 2.1.0 + three: 0.185.1 + web-ifc: 0.0.77 + '@tmcw/togeojson@7.1.2': {} '@tonejs/midi@2.0.28': @@ -8645,6 +8722,10 @@ snapshots: camelcase@7.0.1: {} + camera-controls@3.1.2(three@0.185.1): + dependencies: + three: 0.185.1 + caniuse-lite@1.0.30001810: {} ccount@2.0.1: {} @@ -9335,6 +9416,13 @@ snapshots: strnum: 2.4.2 xml-naming: 0.3.0 + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.3.1 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + fastdom@1.0.12: dependencies: strictdom: 1.0.1 @@ -9347,6 +9435,8 @@ snapshots: fflate@0.8.3: {} + flatbuffers@25.2.10: {} + flexsearch@0.8.212: {} for-each@0.3.5: @@ -10030,6 +10120,8 @@ snapshots: dependencies: js-tokens: 3.0.0 + lru-cache@11.1.0: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -11388,6 +11480,10 @@ snapshots: tapable@2.3.3: {} + three-mesh-bvh@0.9.9(three@0.185.1): + dependencies: + three: 0.185.1 + three@0.185.1: {} tinybench@2.9.0: {} @@ -11687,6 +11783,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-ifc@0.0.77: {} + web-namespaces@2.0.1: {} webidl-conversions@8.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index abdc27dc4..0b476f48e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,6 +33,7 @@ minimumReleaseAgeExclude: - 'styled-exceljs@0.21.6' overrides: + '@thatopen/components>jszip': 3.10.2 'adm-zip@<0.6.0': 0.6.0 'acorn@>=7.0.0 <7.1.1': 7.4.1 'js-yaml@>=4.0.0 <4.3.2': 4.3.2 From b582483cc02dbd2279746549d204173521914379 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:54:16 -0700 Subject: [PATCH 3/5] test(ifc): gate optional entry and official IFC models in public CI --- .github/workflows/public-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml index f5bbd6426..8c0235ef1 100644 --- a/.github/workflows/public-ci.yml +++ b/.github/workflows/public-ci.yml @@ -69,6 +69,9 @@ jobs: - name: Run the issue regression gates run: pnpm test + - name: Verify optional IFC entry ownership and installed CLI execution + run: pnpm --filter @file-viewer/renderer-3d verify:ifc + - name: Verify bounded metadata parsing and Illustrator namespace hints run: pnpm exec vitest run test/metadata-input-safety.spec.ts test/chm-renderer.spec.ts @@ -84,6 +87,11 @@ jobs: - name: Install Chromium and WebKit run: pnpm exec playwright install --with-deps chromium webkit + - name: Verify official IFC4 and IFC4.3 rendering, picking and Worker cleanup + run: | + node packages/renderers/3d/scripts/download-ifc-fixtures.mjs /tmp/ifc-samples + pnpm --filter @file-viewer/renderer-3d verify:ifc-browser /tmp/ifc-samples + - name: Verify PDF Worker cancellation run: | pnpm exec vitest run test/pdf-worker-lifecycle.spec.ts test/pdf-runtime-provenance.spec.ts From c3f1c70fc651e630db421b2f9c5872c4b6260f27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:06:38 +0000 Subject: [PATCH 4/5] fix(ifc): avoid secure-context-only model ID generation Refs #267. Reproduce failure with crypto.randomUUID absent, then verify official IFC4/4.3 models, actual picking, properties and complete Worker cleanup. --- packages/renderers/3d/scripts/verify-ifc-browser.mjs | 3 +++ packages/renderers/3d/src/ifcRuntime.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/renderers/3d/scripts/verify-ifc-browser.mjs b/packages/renderers/3d/scripts/verify-ifc-browser.mjs index 1ef7024c7..8f06d69b4 100644 --- a/packages/renderers/3d/scripts/verify-ifc-browser.mjs +++ b/packages/renderers/3d/scripts/verify-ifc-browser.mjs @@ -127,6 +127,8 @@ try { requests.push({ method: req.method(), url: req.url() }), ); await page.addInitScript(() => { + // Intranet HTTP hosts may not expose this secure-context-only API. + Object.defineProperty(window.crypto, "randomUUID", { value: undefined, configurable: true }); const Original = window.Worker; window.workerCounts = { created: 0, active: 0 }; window.Worker = class extends Original { @@ -147,6 +149,7 @@ try { }); await page.goto(origin); await page.waitForFunction(() => window.entryReady); + assert.equal(await page.evaluate(() => typeof crypto.randomUUID), "undefined"); assert.equal(await page.evaluate(() => workerCounts.created), 0); assert.ok( !requests.some( diff --git a/packages/renderers/3d/src/ifcRuntime.ts b/packages/renderers/3d/src/ifcRuntime.ts index 7dcdd1b49..b94a0ae68 100644 --- a/packages/renderers/3d/src/ifcRuntime.ts +++ b/packages/renderers/3d/src/ifcRuntime.ts @@ -136,7 +136,8 @@ export async function renderIfc( let selectionId = 0; let selectionQueue: Promise = Promise.resolve(); let updatePending = false; - const id = `ifc-${crypto.randomUUID()}`; + // Model identifiers are not security tokens; do not require a secure context. + const id = `ifc-${THREE.MathUtils.generateUUID()}`; let timeout: ReturnType | undefined; let removeCanvasEvents = () => {}; let removeControlEvents = () => {}; From 78f8e5a8fbe9bd16e48e63c11492f15e8a99ec28 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:07:44 -0700 Subject: [PATCH 5/5] docs(ifc): record model ID compatibility and trigger final release checks --- packages/renderers/3d/IFC.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/renderers/3d/IFC.md b/packages/renderers/3d/IFC.md index 9ca771548..e0e590066 100644 --- a/packages/renderers/3d/IFC.md +++ b/packages/renderers/3d/IFC.md @@ -22,6 +22,10 @@ blob:`, WASM execution via `script-src 'wasm-unsafe-eval'`, and the usual local style and data/blob-image permissions of the host. Neither COOP/COEP nor a threaded WASM build is required for the default single import Worker. +Model identifiers use Three.js's non-security UUID utility, not the secure-context-only +`crypto.randomUUID()` API. The browser regression removes that API before loading both +official models and still verifies selection, properties and complete Worker cleanup. + ```ts import { modelRenderer } from '@file-viewer/renderer-3d' import { createIfcRenderer } from '@file-viewer/renderer-3d/ifc'