@file-viewer/renderer-3d 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/IFC.md ADDED
@@ -0,0 +1,156 @@
1
+ # Optional IFC viewer
2
+
3
+ The explicit `@file-viewer/renderer-3d/ifc` entry adds browser-local IFC geometry,
4
+ orbit/pan/zoom, fit-to-model, element selection, basic properties and an advanced
5
+ That Open hook. It does not change the ordinary 3D entry or the frozen Full/Office
6
+ presets. Applications opt in instead of paying the BIM dependency cost by default.
7
+
8
+ ## Install and self-host
9
+
10
+ ```sh
11
+ npm install @file-viewer/renderer-3d @thatopen/components@3.4.8 @thatopen/fragments@3.4.7 web-ifc@0.0.77
12
+ npm install -D esbuild@0.28.2
13
+ npx file-viewer-ifc-assets public/file-viewer/vendor/ifc
14
+ ```
15
+
16
+ The asset command is local: it bundles the import Worker and copies the matching
17
+ Fragments worker and Web-IFC WASM from installed packages. No CDN or online
18
+ conversion service is used. Ship its entire output, including licenses and manifest.
19
+ For a nested deployment use the same custom path in the command and `assetBaseUrl`.
20
+ Use HTTP(S), module Workers and a browser with WebGL 2. CSP needs `worker-src 'self'
21
+ blob:`, WASM execution via `script-src 'wasm-unsafe-eval'`, and the usual local script,
22
+ style and data/blob-image permissions of the host. Neither COOP/COEP nor a threaded
23
+ WASM build is required for the default single import Worker.
24
+
25
+ Model identifiers use Three.js's non-security UUID utility, not the secure-context-only
26
+ `crypto.randomUUID()` API. The browser regression removes that API before loading both
27
+ official models and still verifies selection, properties and complete Worker cleanup.
28
+
29
+ ```ts
30
+ import { modelRenderer } from '@file-viewer/renderer-3d'
31
+ import { createIfcRenderer } from '@file-viewer/renderer-3d/ifc'
32
+
33
+ const options = {
34
+ rendererMode: 'extend',
35
+ renderers: [modelRenderer, createIfcRenderer({
36
+ assetBaseUrl: '/file-viewer/vendor/ifc/',
37
+ fitToModel: true,
38
+ enableSelection: true,
39
+ showProperties: true,
40
+ onSelectionChange(selection) {
41
+ console.log(selection?.name, selection?.globalId, selection?.entityType)
42
+ }
43
+ })]
44
+ }
45
+ // Pass these options to the normal FileViewer component. Set file to the IFC file.
46
+ ```
47
+
48
+ `modelRenderer` owns the base model registry definition; the IFC plugin enhances
49
+ only its `ifc` extension and leaves GLB/STL/STEP and other model routes unchanged.
50
+ When the host already installs the ordinary model renderer, only add the IFC plugin.
51
+
52
+ ## Advanced extension
53
+
54
+ ```ts
55
+ const ifc = createIfcRenderer({
56
+ configure({ components, fragments, world, model, signal, select }) {
57
+ // components/world: That Open Components; fragments: FragmentsModels core.
58
+ // model: the loaded FragmentsModel. No cloned internal substitute objects.
59
+ const handler = () => { /* host-owned integration */ }
60
+ window.addEventListener('my-bim-action', handler, { signal })
61
+ return () => window.removeEventListener('my-bim-action', handler)
62
+ }
63
+ })
64
+ ```
65
+
66
+ ### Pre-import settings and pre-model runtime hook
67
+
68
+ This data-only bridge incorporates the advanced configuration direction proposed
69
+ by @p4535992 in PR #275, on the owned-Worker architecture from PR #276. It does not
70
+ add the draft's duplicate runtime or separate capability/assets packages.
71
+
72
+ ```ts
73
+ createIfcRenderer({
74
+ thatOpen: {
75
+ importer: {
76
+ webIfcSettings: { COORDINATE_TO_ORIGIN: true, CIRCLE_SEGMENTS: 24 },
77
+ geometryProcessSettings: { threshold: 3000 },
78
+ includeMaterialProperties: true
79
+ },
80
+ fragments: { settings: { maxUpdateRate: 80 } }
81
+ },
82
+ configureRuntime({ components, fragments, world, signal }) {
83
+ // Actual adapter-owned objects, before fragments.load() creates the model.
84
+ // Configure Components/camera/scene here, not through private-field assignment.
85
+ const handler = () => { /* application-specific integration */ }
86
+ window.addEventListener('bim-settings', handler, { signal })
87
+ return () => window.removeEventListener('bim-settings', handler)
88
+ },
89
+ configure({ model }) {
90
+ // Existing post-load hook remains available.
91
+ }
92
+ })
93
+ ```
94
+
95
+ `thatOpen.importer` accepts existing public data fields on the installed
96
+ `IfcImporter`; `thatOpen.fragments.settings` accepts public writable fields on
97
+ `FragmentsModels.settings`. These are advanced, upstream-version-coupled APIs,
98
+ not a normalization of every That Open release. Omitted fields preserve defaults.
99
+ Nested Loader/geometry bags are merged; native Sets/Maps replace contents while
100
+ retaining library-owned collection instances. Use native `Set` for
101
+ `attributesToExclude` and native `Map` for `relations`.
102
+
103
+ Settings are copied before Worker allocation or copying file bytes. Only plain
104
+ data, finite numbers, arrays and native Sets/Maps are accepted, limited to 2,048
105
+ nodes, eight nesting levels and 65,536 cumulative string/key characters. Functions,
106
+ accessors, class instances, cycles, prototype/private keys and custom collection
107
+ properties are rejected. WASM locations, executable methods and Worker ownership
108
+ remain adapter-controlled. Unknown top-level fields fail rather than being ignored.
109
+ The Worker validates settings again before parsing. These shape/size guards do
110
+ not replace upstream documentation for valid option values; configuration is
111
+ trusted application code, never document-supplied executable metadata.
112
+
113
+ Both hooks may be asynchronous and return synchronous cleanup. Cleanup runs once
114
+ in reverse registration order, including late completion after cancellation. A
115
+ failing cleanup does not prevent other hooks or Workers/WebGL from being disposed.
116
+ Never dispose adapter-owned objects or replace their Worker/lifecycle methods.
117
+
118
+ The adapter owns and disposes these objects. Do not dispose them in the hook.
119
+ Return cleanup for your own resources. A late async hook is cleaned up after
120
+ cancellation. The explicit `renderFileViewerIfc` API also returns `select(id|null)`,
121
+ `fitToModel()` and an idempotent asynchronous `unmount()`.
122
+
123
+ Parsing runs in a dedicated module Worker. Closing/changing the file aborts imports,
124
+ terminates import workers, releases Fragments workers/models and disposes WebGL and
125
+ camera controls. Input size defaults to 512 MiB and the loading timeout to 120 seconds;
126
+ `maxFileBytes` and `loadTimeoutMs` allow smaller product-specific limits. These are
127
+ safety limits, not promises that every 512 MiB model is interactive on every device.
128
+
129
+ ## Scope and licenses
130
+
131
+ The initial scope is visual inspection, not authoring, BCF, clash detection,
132
+ measurements or complete BIM semantics. IFC4 and IFC4.3 building samples are used
133
+ for the repeatable browser gate. Fonts, textures and references outside the IFC
134
+ are not fetched from remote services. Complex or unsupported geometry remains
135
+ subject to Web-IFC's capabilities; parse failures are explicit.
136
+
137
+ That Open Components and Fragments are MIT. Web-IFC is MPL-2.0, isolated behind this
138
+ optional entry; the File Viewer adapter remains Apache-2.0. Preserve the copied
139
+ MPL license, corresponding-source notice and bundled legal notices when distributing
140
+ WASM/worker files. Fragments' upstream MIT text is retained in `licenses/` because
141
+ its published tarball does not contain its root license file. Source:
142
+ https://github.com/ThatOpen/engine_fragment/blob/main/LICENSE.md
143
+
144
+ ## Regression fixture attribution
145
+
146
+ buildingSMART International Ltd., Certification-datasets, CC BY 4.0:
147
+ https://github.com/buildingSMART/Certification-datasets
148
+ Revision: `80d976a9b193a26a8e928c3e79bff67af1de68a8`.
149
+
150
+ - `IFC 4.0.2.1 (IFC 4 ADD2 TC1)/Simple-Scene/Building-Architecture.ifc`
151
+ - `IFC 4.3.2.0 (IFC 4.3 ADD2)/Simple-Scene/Building-Architecture.ifc`
152
+
153
+ The input files are unmodified. Test screenshots are rendered derivatives.
154
+ Fixture bytes are supplied separately; the browser script checks their SHA-256.
155
+ Run `pnpm --filter @file-viewer/renderer-3d verify:ifc-browser /path/to/fixtures`
156
+ with `ifc4.ifc` and `ifc43.ifc` in that directory after building the package.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { dirname, resolve, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ mkdir,
8
+ copyFile,
9
+ readFile,
10
+ writeFile,
11
+ mkdtemp,
12
+ rm,
13
+ readdir,
14
+ } from "node:fs/promises";
15
+ import { createHash } from "node:crypto";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ async function packageDir(name, resolver = require) {
20
+ let current = dirname(resolver.resolve(name));
21
+ for (;;) {
22
+ try {
23
+ const metadata = JSON.parse(
24
+ await readFile(join(current, "package.json"), "utf8"),
25
+ );
26
+ if (metadata.name === name) return { path: current, metadata };
27
+ } catch {}
28
+ const parent = dirname(current);
29
+ if (parent === current)
30
+ throw new Error(`Cannot locate ${name} package root`);
31
+ current = parent;
32
+ }
33
+ }
34
+ export async function copyIfcAssets(destination) {
35
+ const [webIfc, fragments, three] = await Promise.all(
36
+ ["web-ifc", "@thatopen/fragments", "three"].map((name) => packageDir(name)),
37
+ );
38
+ const fragmentRequire = createRequire(join(fragments.path, "package.json"));
39
+ const packages = [
40
+ webIfc,
41
+ fragments,
42
+ three,
43
+ ...(await Promise.all(
44
+ ["earcut", "flatbuffers", "lru-cache", "pako"].map((name) =>
45
+ packageDir(name, fragmentRequire),
46
+ ),
47
+ )),
48
+ ];
49
+ if (
50
+ webIfc.metadata.version !== "0.0.77" ||
51
+ fragments.metadata.version !== "3.4.7"
52
+ )
53
+ throw new Error(
54
+ "IFC assets must match the tested web-ifc@0.0.77 and @thatopen/fragments@3.4.7 engines",
55
+ );
56
+ const { build } = await import("esbuild");
57
+ destination = resolve(destination);
58
+ await mkdir(dirname(destination), { recursive: true });
59
+ const stage = await mkdtemp(join(dirname(destination), ".ifc-assets-"));
60
+ try {
61
+ await copyFile(
62
+ join(webIfc.path, "web-ifc.wasm"),
63
+ join(stage, "web-ifc.wasm"),
64
+ );
65
+ await copyFile(
66
+ join(webIfc.path, "web-ifc-mt.wasm"),
67
+ join(stage, "web-ifc-mt.wasm"),
68
+ );
69
+ await copyFile(
70
+ join(fragments.path, "dist/Worker/worker.mjs"),
71
+ join(stage, "fragments.worker.mjs"),
72
+ );
73
+ await build({
74
+ entryPoints: [join(packageRoot, "dist/ifc-import.worker.js")],
75
+ outfile: join(stage, "ifc-import.worker.js"),
76
+ bundle: true,
77
+ format: "esm",
78
+ platform: "browser",
79
+ target: "es2022",
80
+ legalComments: "linked",
81
+ minify: true,
82
+ logLevel: "silent",
83
+ });
84
+ const licenses = join(stage, "licenses");
85
+ await mkdir(licenses);
86
+ await copyFile(
87
+ join(packageRoot, "licenses/thatopen-fragments-MIT.txt"),
88
+ join(licenses, "thatopen-fragments-MIT.txt"),
89
+ );
90
+ for (const pkg of packages) {
91
+ const names = (await readdir(pkg.path)).filter((name) =>
92
+ /^licen[cs]e(?:[.-]|$)/i.test(name),
93
+ );
94
+ for (const name of names)
95
+ await copyFile(
96
+ join(pkg.path, name),
97
+ join(licenses, `${pkg.metadata.name.replace(/[@/]/g, "_")}-${name}`),
98
+ );
99
+ }
100
+ const notice = [
101
+ "Optional IFC runtime assets for File Viewer.",
102
+ "These assets are not loaded by the ordinary model/Office entry.",
103
+ "web-ifc is MPL-2.0. Its WASM and bundled import code are unmodified upstream implementations.",
104
+ "Corresponding source and build instructions: https://github.com/ThatOpen/engine_web-ifc (release 0.0.77).",
105
+ "Fragments is MIT: https://github.com/ThatOpen/engine_fragment (release 3.4.7).",
106
+ "Preserve licenses/, the linked worker legal notices and this notice when redistributing.",
107
+ ...packages.map(
108
+ (pkg) =>
109
+ `${pkg.metadata.name}@${pkg.metadata.version}: ${pkg.metadata.license || "See package license"}`,
110
+ ),
111
+ "",
112
+ ].join("\n");
113
+ await writeFile(join(stage, "NOTICE.txt"), notice);
114
+ const files = {};
115
+ for (const name of await readdir(stage)) {
116
+ if (name === "licenses") continue;
117
+ const bytes = await readFile(join(stage, name));
118
+ files[name] = {
119
+ bytes: bytes.length,
120
+ sha256: createHash("sha256").update(bytes).digest("hex"),
121
+ };
122
+ }
123
+ await writeFile(
124
+ join(stage, "manifest.json"),
125
+ JSON.stringify(
126
+ {
127
+ schema: 1,
128
+ packages: packages.map((pkg) => ({
129
+ name: pkg.metadata.name,
130
+ version: pkg.metadata.version,
131
+ license: pkg.metadata.license,
132
+ })),
133
+ files,
134
+ },
135
+ null,
136
+ 2,
137
+ ) + "\n",
138
+ );
139
+ // Only replace our own asset names, never remove a user's destination directory.
140
+ await mkdir(join(destination, "licenses"), { recursive: true });
141
+ for (const name of await readdir(stage)) {
142
+ if (name === "licenses") {
143
+ for (const license of await readdir(licenses))
144
+ await copyFile(
145
+ join(licenses, license),
146
+ join(destination, "licenses", license),
147
+ );
148
+ } else await copyFile(join(stage, name), join(destination, name));
149
+ }
150
+ return { destination, files };
151
+ } finally {
152
+ await rm(stage, { recursive: true, force: true });
153
+ }
154
+ }
155
+ function isEntryPoint() {
156
+ try {
157
+ // npm/pnpm execute bin entries through a symlink; import.meta.url points
158
+ // at the real module. Do not silently skip the installed CLI in that case.
159
+ return Boolean(process.argv[1]) &&
160
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
161
+ } catch {
162
+ // Importing this helper from eval/another program is not CLI execution.
163
+ return false;
164
+ }
165
+ }
166
+ if (isEntryPoint()) {
167
+ if (process.argv.includes("--help")) {
168
+ console.log("Usage: file-viewer-ifc-assets [destination-directory]");
169
+ process.exit(0);
170
+ }
171
+ if (process.argv.length > 3 || process.argv[2]?.startsWith("-")) {
172
+ console.error("Expected one destination directory");
173
+ process.exit(1);
174
+ }
175
+ copyIfcAssets(process.argv[2] || "public/file-viewer/vendor/ifc")
176
+ .then((result) =>
177
+ console.log(`IFC assets installed: ${result.destination}`),
178
+ )
179
+ .catch((error) => {
180
+ console.error(error.message);
181
+ process.exitCode = 1;
182
+ });
183
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { applyIfcSettings, copyIfcImporterSettings } from "./ifcSettings.js";
2
+ import { IfcImporter } from "@thatopen/fragments";
3
+ // Compiled into a self-hosted module Worker by the optional asset installer.
4
+ const scope = globalThis;
5
+ let active = false;
6
+ scope.onmessage = async ({ data }) => {
7
+ if (active)
8
+ return;
9
+ active = true;
10
+ try {
11
+ const importer = new IfcImporter();
12
+ importer.wasm = { path: data.wasmPath, absolute: true };
13
+ importer.webIfcSettings = { COORDINATE_TO_ORIGIN: true };
14
+ importer.includeUniqueAttributes = true;
15
+ importer.includeRelationNames = true;
16
+ applyIfcSettings(importer, copyIfcImporterSettings(data.importerSettings));
17
+ const result = await importer.process({
18
+ bytes: new Uint8Array(data.bytes),
19
+ progressCallback: (progress) => scope.postMessage({ kind: "progress", progress }),
20
+ });
21
+ // slice owns the exact byte range even when the importer returns a view.
22
+ const bytes = Uint8Array.from(result).buffer;
23
+ scope.postMessage({ kind: "ready", bytes }, [bytes]);
24
+ }
25
+ catch (error) {
26
+ scope.postMessage({
27
+ kind: "error",
28
+ message: error instanceof Error ? error.message : String(error),
29
+ });
30
+ }
31
+ };
package/dist/ifc.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { FileRenderContext, FileViewerRendererPlugin, FileRenderHandler, RendererDefinition } from "@file-viewer/core";
2
+ import type * as OBC from "@thatopen/components";
3
+ import type * as FRAGS from "@thatopen/fragments";
4
+ export interface IfcSelection {
5
+ localId: number;
6
+ name: string;
7
+ globalId: string;
8
+ entityType: string;
9
+ attributes: FRAGS.ItemData;
10
+ }
11
+ export interface IfcExtensionContext {
12
+ components: OBC.Components;
13
+ fragments: FRAGS.FragmentsModels;
14
+ world: OBC.SimpleWorld<OBC.SimpleScene, OBC.SimpleCamera, OBC.SimpleRenderer>;
15
+ model: FRAGS.FragmentsModel;
16
+ signal: AbortSignal;
17
+ select: (localId: number | null) => Promise<IfcSelection | null>;
18
+ }
19
+ /** Advanced runtime objects are adapter-owned; return cleanup only for host resources. */
20
+ export type IfcRuntimeContext = Pick<IfcExtensionContext, "components" | "fragments" | "world" | "signal">;
21
+ export interface IfcThatOpenOptions {
22
+ /** Public IfcImporter data fields; no executable methods, WASM or Worker overrides. */
23
+ importer?: Readonly<Record<string, unknown>>;
24
+ /** Public FragmentsModels.settings fields, not constructor/Worker ownership. */
25
+ fragments?: {
26
+ settings?: Readonly<Record<string, unknown>>;
27
+ };
28
+ }
29
+ export interface IfcViewerOptions {
30
+ /** Optional pre-import data settings; defaults are unchanged when omitted. */
31
+ thatOpen?: IfcThatOpenOptions;
32
+ /** Runs after runtime creation, before model loading. Return host-resource cleanup. */
33
+ configureRuntime?: (context: IfcRuntimeContext) => void | (() => void) | Promise<void | (() => void)>;
34
+ /** Directory installed by file-viewer-ifc-assets. Defaults to /file-viewer/vendor/ifc/. */
35
+ assetBaseUrl?: string | URL;
36
+ fitToModel?: boolean;
37
+ enableSelection?: boolean;
38
+ showProperties?: boolean;
39
+ /** Whole-input limit, before a transferable copy is made. Default: 512 MiB. */
40
+ maxFileBytes?: number;
41
+ /** Bound stalled import/worker setup. Default: 120 seconds. */
42
+ loadTimeoutMs?: number;
43
+ onSelectionChange?: (selection: IfcSelection | null) => void;
44
+ /** Advanced, opt-in access. Return cleanup for resources created by the hook. */
45
+ configure?: (context: IfcExtensionContext) => void | (() => void) | Promise<void | (() => void)>;
46
+ }
47
+ export interface IfcViewerInstance {
48
+ $el: HTMLElement;
49
+ unmount(): Promise<void>;
50
+ fitToModel(): Promise<void>;
51
+ select(localId: number | null): Promise<IfcSelection | null>;
52
+ }
53
+ export declare const ifcRendererDefinition: RendererDefinition;
54
+ export declare function createIfcRenderer(options?: IfcViewerOptions): FileViewerRendererPlugin<FileRenderHandler<IfcViewerInstance, HTMLDivElement>>;
55
+ export declare function renderFileViewerIfc(buffer: ArrayBuffer, target: HTMLDivElement, context?: FileRenderContext, options?: IfcViewerOptions): Promise<IfcViewerInstance>;
56
+ export declare const ifcRenderer: FileViewerRendererPlugin<FileRenderHandler<IfcViewerInstance, HTMLDivElement>>;
57
+ export default ifcRenderer;
package/dist/ifc.js ADDED
@@ -0,0 +1,31 @@
1
+ export const ifcRendererDefinition = {
2
+ id: "ifc",
3
+ label: "IFC BIM",
4
+ category: "model",
5
+ extensions: [],
6
+ packageName: "@file-viewer/renderer-3d",
7
+ supportLevel: "structured",
8
+ status: "experimental",
9
+ enhancesRendererId: "model",
10
+ enhancesExtensions: ["ifc"],
11
+ };
12
+ export function createIfcRenderer(options = {}) {
13
+ return {
14
+ id: "file-viewer-renderer-ifc",
15
+ label: "Flyfish optional IFC viewer",
16
+ definitions: [ifcRendererDefinition],
17
+ handlers: [
18
+ {
19
+ rendererId: "ifc",
20
+ handler: (buffer, target, _type, context) => renderFileViewerIfc(buffer, target, context, options),
21
+ },
22
+ ],
23
+ };
24
+ }
25
+ export async function renderFileViewerIfc(buffer, target, context, options = {}) {
26
+ // Optional engines never enter the normal model/Office entry or initial bundle.
27
+ const { renderIfc } = await import("./ifcRuntime.js");
28
+ return renderIfc(buffer, target, context, options);
29
+ }
30
+ export const ifcRenderer = createIfcRenderer();
31
+ export default ifcRenderer;
@@ -0,0 +1,3 @@
1
+ import type { FileRenderContext } from "@file-viewer/core";
2
+ import type { IfcViewerInstance, IfcViewerOptions } from "./ifc.js";
3
+ export declare function renderIfc(buffer: ArrayBuffer, target: HTMLDivElement, context: FileRenderContext | undefined, options: IfcViewerOptions): Promise<IfcViewerInstance>;
@@ -0,0 +1,500 @@
1
+ import { applyIfcSettings, copyIfcImporterSettings, copyIfcSettings, } from "./ifcSettings.js";
2
+ import * as THREE from "three";
3
+ import * as OBC from "@thatopen/components";
4
+ import * as FRAGS from "@thatopen/fragments";
5
+ 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%}}`;
6
+ const abortError = () => new DOMException("IFC preview cancelled", "AbortError");
7
+ const attribute = (data, ...names) => {
8
+ for (const name of names) {
9
+ const value = data[name];
10
+ if (value &&
11
+ !Array.isArray(value) &&
12
+ "value" in value &&
13
+ value.value != null)
14
+ return String(value.value);
15
+ }
16
+ return "";
17
+ };
18
+ export async function renderIfc(buffer, target, context, options) {
19
+ if (context?.signal?.aborted)
20
+ throw abortError();
21
+ const maxBytes = options.maxFileBytes ?? 512 * 1024 * 1024;
22
+ const timeoutMs = options.loadTimeoutMs ?? 120_000;
23
+ if (!Number.isFinite(maxBytes) ||
24
+ maxBytes <= 0 ||
25
+ buffer.byteLength > maxBytes)
26
+ throw new Error("IFC input exceeds the configured size limit");
27
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1)
28
+ throw new Error("Invalid IFC load timeout");
29
+ const importerSettings = copyIfcImporterSettings(options.thatOpen?.importer);
30
+ const fragmentsSettings = copyIfcSettings(options.thatOpen?.fragments?.settings, "IFC Fragments settings");
31
+ const header = new TextDecoder().decode(buffer.slice(0, 65536));
32
+ if (!/^\s*ISO-10303-21\s*;/i.test(header.replace(/^\uFEFF/, "")) ||
33
+ !/FILE_SCHEMA\s*\(\s*\(\s*['"]IFC/i.test(header))
34
+ throw new Error("The file is not an IFC STEP document");
35
+ if (typeof Worker === "undefined")
36
+ throw new Error("IFC preview requires Web Workers");
37
+ const assetBase = new URL(String(options.assetBaseUrl ?? "/file-viewer/vendor/ifc/"), target.ownerDocument.baseURI);
38
+ if (!/^https?:$/.test(assetBase.protocol))
39
+ throw new Error("IFC assets must use a local HTTP(S) asset directory");
40
+ if (!assetBase.pathname.endsWith("/"))
41
+ assetBase.pathname += "/";
42
+ const cn = String(context?.options?.locale || "").startsWith("zh");
43
+ const jp = String(context?.options?.locale || "").startsWith("ja");
44
+ const words = cn
45
+ ? [
46
+ "适合窗口",
47
+ "清除选择",
48
+ "属性",
49
+ "正在解析 IFC…",
50
+ "点击构件查看属性",
51
+ "构件",
52
+ ]
53
+ : jp
54
+ ? [
55
+ "全体表示",
56
+ "選択解除",
57
+ "プロパティ",
58
+ "IFC を読み込み中…",
59
+ "要素を選択してプロパティを表示",
60
+ "要素",
61
+ ]
62
+ : [
63
+ "Fit model",
64
+ "Clear selection",
65
+ "Properties",
66
+ "Loading IFC…",
67
+ "Select an element to inspect its properties",
68
+ "elements",
69
+ ];
70
+ const doc = target.ownerDocument;
71
+ const root = doc.createElement("div");
72
+ root.className = "fv-ifc";
73
+ root.dataset.ifcStatus = "loading";
74
+ const style = doc.createElement("style");
75
+ style.textContent = css;
76
+ const toolbar = doc.createElement("div");
77
+ toolbar.className = "fv-ifc-toolbar";
78
+ const button = (text) => {
79
+ const value = doc.createElement("button");
80
+ value.type = "button";
81
+ value.textContent = text;
82
+ return value;
83
+ };
84
+ const fitButton = button(words[0]);
85
+ const clearButton = button(words[1]);
86
+ const propertiesButton = button(words[2]);
87
+ const summary = doc.createElement("span");
88
+ summary.setAttribute("aria-live", "polite");
89
+ toolbar.append(fitButton, clearButton, propertiesButton, summary);
90
+ const body = doc.createElement("div");
91
+ body.className = "fv-ifc-body";
92
+ const stage = doc.createElement("div");
93
+ stage.className = "fv-ifc-stage";
94
+ const status = doc.createElement("div");
95
+ status.className = "fv-ifc-status";
96
+ status.setAttribute("role", "status");
97
+ status.textContent = words[3];
98
+ const properties = doc.createElement("aside");
99
+ properties.className = "fv-ifc-properties";
100
+ properties.hidden = options.showProperties === false;
101
+ properties.textContent = words[4];
102
+ propertiesButton.setAttribute("aria-expanded", String(!properties.hidden));
103
+ stage.append(status);
104
+ body.append(stage, properties);
105
+ root.append(toolbar, body);
106
+ target.replaceChildren(style, root);
107
+ const controller = new AbortController();
108
+ let disposed = false;
109
+ let importWorker;
110
+ let components;
111
+ let fragments;
112
+ let world;
113
+ let model;
114
+ const extensionCleanups = [];
115
+ let ready = false;
116
+ let selectionId = 0;
117
+ let selectionQueue = Promise.resolve();
118
+ let updatePending = false;
119
+ // Model identifiers are not security tokens; do not require a secure context.
120
+ const id = `ifc-${THREE.MathUtils.generateUUID()}`;
121
+ let timeout;
122
+ let removeCanvasEvents = () => { };
123
+ let removeControlEvents = () => { };
124
+ let disposePromise;
125
+ const ensureLive = () => {
126
+ if (disposed || controller.signal.aborted)
127
+ throw controller.signal.reason ?? abortError();
128
+ };
129
+ const showError = (error) => {
130
+ if (disposed)
131
+ return;
132
+ root.dataset.ifcStatus = "error";
133
+ status.hidden = false;
134
+ status.textContent = error instanceof Error ? error.message : String(error);
135
+ };
136
+ const update = async () => {
137
+ if (disposed || !fragments || updatePending)
138
+ return;
139
+ updatePending = true;
140
+ try {
141
+ await fragments.update();
142
+ }
143
+ catch (error) {
144
+ showError(error);
145
+ }
146
+ finally {
147
+ updatePending = false;
148
+ }
149
+ };
150
+ const unmount = () => {
151
+ if (disposePromise)
152
+ return disposePromise;
153
+ // Assign before abort/cleanup callbacks can reenter unmount(). Every caller
154
+ // must await the same complete resource teardown, not an early promise.
155
+ let finish;
156
+ let fail;
157
+ disposePromise = new Promise((resolve, reject) => {
158
+ finish = resolve;
159
+ fail = reject;
160
+ });
161
+ void (async () => {
162
+ disposed = true;
163
+ ready = false;
164
+ selectionId++;
165
+ if (!controller.signal.aborted)
166
+ controller.abort(abortError());
167
+ clearTimeout(timeout);
168
+ importWorker?.terminate();
169
+ importWorker = undefined;
170
+ context?.signal?.removeEventListener("abort", onAbort);
171
+ removeCanvasEvents();
172
+ removeControlEvents();
173
+ const currentComponents = components;
174
+ components = undefined;
175
+ const currentFragments = fragments;
176
+ fragments = undefined;
177
+ const cleanups = extensionCleanups.splice(0).reverse();
178
+ root.remove();
179
+ style.remove();
180
+ try {
181
+ const errors = [];
182
+ for (const cleanup of cleanups) {
183
+ try {
184
+ cleanup();
185
+ }
186
+ catch (error) {
187
+ errors.push(error);
188
+ }
189
+ }
190
+ if (errors.length)
191
+ throw new AggregateError(errors, "IFC extension cleanup failed");
192
+ }
193
+ finally {
194
+ // Stop the frame loop before releasing worker-owned model geometry.
195
+ if (currentComponents)
196
+ currentComponents.enabled = false;
197
+ try {
198
+ if (currentFragments) {
199
+ // abort() routes through the upstream connection and creates a Worker
200
+ // for an unknown model ID. Before load(), there is nothing to abort.
201
+ if (currentFragments.models.list.has(id))
202
+ currentFragments.abort(id);
203
+ await currentFragments.dispose();
204
+ }
205
+ }
206
+ finally {
207
+ currentComponents?.dispose();
208
+ }
209
+ }
210
+ })().then(finish, fail);
211
+ return disposePromise;
212
+ };
213
+ const onAbort = () => {
214
+ void unmount().catch(() => { });
215
+ };
216
+ context?.signal?.addEventListener("abort", onAbort, { once: true });
217
+ const withCancellation = (promise) => new Promise((resolve, reject) => {
218
+ const abort = () => reject(controller.signal.reason ?? abortError());
219
+ if (controller.signal.aborted) {
220
+ abort();
221
+ return;
222
+ }
223
+ controller.signal.addEventListener("abort", abort, { once: true });
224
+ promise
225
+ .then(resolve, reject)
226
+ .finally(() => controller.signal.removeEventListener("abort", abort));
227
+ });
228
+ const fitToModel = async () => {
229
+ ensureLive();
230
+ if (!world || !model)
231
+ return;
232
+ const box = model.box.clone();
233
+ if (box.isEmpty())
234
+ throw new Error("IFC model contains no renderable geometry");
235
+ const center = box.getCenter(new THREE.Vector3());
236
+ const size = Math.max(box.getSize(new THREE.Vector3()).length(), 1);
237
+ world.camera.three.near = Math.max(0.01, size / 10000);
238
+ world.camera.three.far = Math.max(1000, size * 100);
239
+ world.camera.three.updateProjectionMatrix();
240
+ await world.camera.controls.setLookAt(center.x + size, center.y + size * 0.8, center.z + size, center.x, center.y, center.z, false);
241
+ await world.camera.controls.fitToBox(box, false);
242
+ await fragments?.update(true);
243
+ };
244
+ const renderProperties = (selection) => {
245
+ properties.replaceChildren();
246
+ if (!selection) {
247
+ properties.textContent = words[4];
248
+ return;
249
+ }
250
+ const title = doc.createElement("h3");
251
+ title.textContent =
252
+ selection.name || selection.entityType || String(selection.localId);
253
+ properties.append(title);
254
+ const list = doc.createElement("dl");
255
+ let count = 0;
256
+ const append = (label, value) => {
257
+ if (++count > 300)
258
+ return;
259
+ const key = doc.createElement("dt");
260
+ key.textContent = label.slice(0, 200);
261
+ const item = doc.createElement("dd");
262
+ item.textContent = String(value ?? "").slice(0, 2048);
263
+ list.append(key, item);
264
+ };
265
+ append("IFC type", selection.entityType);
266
+ append("GlobalId", selection.globalId);
267
+ append("Local ID", selection.localId);
268
+ const visit = (data, prefix = "", depth = 0) => {
269
+ if (depth > 3 || count >= 300)
270
+ return;
271
+ for (const [key, value] of Object.entries(data)) {
272
+ if (count >= 300)
273
+ break;
274
+ if (Array.isArray(value)) {
275
+ for (const child of value.slice(0, 30))
276
+ visit(child, `${prefix}${key} / `, depth + 1);
277
+ }
278
+ else if (value && "value" in value)
279
+ append(prefix + key, value.value);
280
+ }
281
+ };
282
+ visit(selection.attributes);
283
+ properties.append(list);
284
+ };
285
+ const select = (localId) => {
286
+ const request = ++selectionId;
287
+ const next = selectionQueue
288
+ .catch(() => { })
289
+ .then(async () => {
290
+ ensureLive();
291
+ if (!model || !ready || request !== selectionId)
292
+ return null;
293
+ if (localId !== null && (!Number.isSafeInteger(localId) || localId < 0))
294
+ throw new Error("Invalid IFC local ID");
295
+ let selection = null;
296
+ if (localId !== null) {
297
+ const [data] = await model.getItemsData([localId], {
298
+ attributesDefault: true,
299
+ relations: {
300
+ IsDefinedBy: { attributes: true, relations: true },
301
+ DefinesOccurrence: { attributes: false, relations: false },
302
+ },
303
+ });
304
+ ensureLive();
305
+ if (request !== selectionId)
306
+ return null;
307
+ if (!data)
308
+ throw new Error("IFC item not found");
309
+ selection = {
310
+ localId,
311
+ name: attribute(data, "Name"),
312
+ globalId: attribute(data, "GlobalId", "_guid"),
313
+ entityType: attribute(data, "_category", "type"),
314
+ attributes: data,
315
+ };
316
+ }
317
+ await model.resetHighlight();
318
+ if (selection)
319
+ await model.highlight([selection.localId], {
320
+ color: new THREE.Color("#3b82f6"),
321
+ renderedFaces: FRAGS.RenderedFaces.TWO,
322
+ opacity: 1,
323
+ transparent: false,
324
+ });
325
+ ensureLive();
326
+ if (request !== selectionId)
327
+ return null;
328
+ root.dataset.ifcSelected = selection ? String(selection.localId) : "";
329
+ renderProperties(selection);
330
+ await fragments?.update(true);
331
+ options.onSelectionChange?.(selection);
332
+ return selection;
333
+ });
334
+ selectionQueue = next;
335
+ return next;
336
+ };
337
+ const configureExtension = async (hook) => {
338
+ ensureLive();
339
+ const pending = Promise.resolve()
340
+ .then(() => {
341
+ ensureLive();
342
+ return hook();
343
+ })
344
+ .then((cleanup) => {
345
+ if (cleanup !== undefined && typeof cleanup !== "function")
346
+ throw new TypeError("IFC extension hook must return a cleanup function or undefined");
347
+ if (disposed)
348
+ cleanup?.();
349
+ else if (cleanup)
350
+ extensionCleanups.push(cleanup);
351
+ });
352
+ await withCancellation(pending);
353
+ ensureLive();
354
+ };
355
+ fitButton.addEventListener("click", () => {
356
+ void fitToModel().catch(showError);
357
+ });
358
+ clearButton.addEventListener("click", () => {
359
+ void select(null).catch(showError);
360
+ });
361
+ propertiesButton.addEventListener("click", () => {
362
+ properties.hidden = !properties.hidden;
363
+ propertiesButton.setAttribute("aria-expanded", String(!properties.hidden));
364
+ });
365
+ fitButton.disabled = clearButton.disabled = true;
366
+ try {
367
+ const importResult = new Promise((resolve, reject) => {
368
+ importWorker = new Worker(new URL("ifc-import.worker.js", assetBase), {
369
+ type: "module",
370
+ name: "file-viewer-ifc-import",
371
+ });
372
+ importWorker.onmessage = ({ data }) => {
373
+ if (data?.kind === "progress")
374
+ summary.textContent = `${Math.round(Math.max(0, Math.min(1, Number(data.progress) || 0)) * 100)}%`;
375
+ else if (data?.kind === "ready" && data.bytes instanceof ArrayBuffer)
376
+ resolve(new Uint8Array(data.bytes));
377
+ else if (data?.kind === "error")
378
+ reject(new Error(`IFC import failed: ${String(data.message)}`));
379
+ };
380
+ importWorker.onerror = (event) => reject(new Error(`IFC worker failed. Install the self-hosted assets at ${assetBase.href}: ${event.message}`));
381
+ importWorker.onmessageerror = () => reject(new Error("Invalid IFC worker response"));
382
+ const copy = buffer.slice(0);
383
+ importWorker.postMessage({ bytes: copy, wasmPath: assetBase.href, importerSettings }, [copy]);
384
+ });
385
+ timeout = setTimeout(() => {
386
+ controller.abort(new Error("IFC loading timed out"));
387
+ onAbort();
388
+ }, timeoutMs);
389
+ const bytes = await withCancellation(importResult);
390
+ ensureLive();
391
+ importWorker?.terminate();
392
+ importWorker = undefined;
393
+ components = new OBC.Components();
394
+ world = components
395
+ .get(OBC.Worlds)
396
+ .create();
397
+ world.scene = new OBC.SimpleScene(components);
398
+ world.renderer = new OBC.SimpleRenderer(components, stage, {
399
+ antialias: true,
400
+ alpha: false,
401
+ });
402
+ world.renderer.showLogo = false;
403
+ world.renderer.three.setPixelRatio(Math.min(target.ownerDocument.defaultView?.devicePixelRatio || 1, 2));
404
+ world.camera = new OBC.SimpleCamera(components);
405
+ world.scene.setup({ backgroundColor: new THREE.Color("#f5f7fa") });
406
+ components.init();
407
+ fragments = new FRAGS.FragmentsModels(new URL("fragments.worker.mjs", assetBase).href, { maxWorkers: 2 });
408
+ applyIfcSettings(fragments.settings, fragmentsSettings);
409
+ if (options.configureRuntime) {
410
+ await configureExtension(() => options.configureRuntime({
411
+ components: components,
412
+ fragments: fragments,
413
+ world: world,
414
+ signal: controller.signal,
415
+ }));
416
+ }
417
+ model = await withCancellation(fragments.load(bytes, { modelId: id }));
418
+ ensureLive();
419
+ model.useCamera(world.camera.three);
420
+ world.scene.three.add(model.object);
421
+ world.camera.controls.addEventListener("update", update);
422
+ removeControlEvents = () => world?.camera.controls.removeEventListener("update", update);
423
+ const ids = await withCancellation(model.getItemsIdsWithGeometry());
424
+ ensureLive();
425
+ if (!ids.length)
426
+ throw new Error("IFC model contains no renderable geometry");
427
+ root.dataset.ifcElementCount = String(ids.length);
428
+ root.dataset.ifcFirstElement = String(ids[0]);
429
+ summary.textContent = `${ids.length} ${words[5]}`;
430
+ if (options.fitToModel !== false)
431
+ await withCancellation(fitToModel());
432
+ ready = true;
433
+ const canvas = world.renderer.three.domElement;
434
+ let start;
435
+ const down = (event) => {
436
+ if (event.button === 0)
437
+ start = { x: event.clientX, y: event.clientY };
438
+ };
439
+ const up = (event) => {
440
+ const original = start;
441
+ start = undefined;
442
+ if (!original ||
443
+ options.enableSelection === false ||
444
+ Math.hypot(event.clientX - original.x, event.clientY - original.y) >
445
+ 5 ||
446
+ !model ||
447
+ !world)
448
+ return;
449
+ // Fragment raycasting takes client-pixel coordinates, not normalized NDC.
450
+ void model
451
+ .raycast({
452
+ camera: world.camera.three,
453
+ mouse: new THREE.Vector2(event.clientX, event.clientY),
454
+ dom: canvas,
455
+ })
456
+ .then((hit) => {
457
+ if (!disposed)
458
+ return select(hit?.localId ?? null);
459
+ })
460
+ .catch(showError);
461
+ };
462
+ canvas.addEventListener("pointerdown", down);
463
+ canvas.addEventListener("pointerup", up);
464
+ removeCanvasEvents = () => {
465
+ canvas.removeEventListener("pointerdown", down);
466
+ canvas.removeEventListener("pointerup", up);
467
+ };
468
+ if (options.configure) {
469
+ await configureExtension(() => options.configure({
470
+ components: components,
471
+ fragments: fragments,
472
+ world: world,
473
+ model: model,
474
+ signal: controller.signal,
475
+ select,
476
+ }));
477
+ }
478
+ clearTimeout(timeout);
479
+ root.dataset.ifcStatus = "ready";
480
+ status.hidden = true;
481
+ fitButton.disabled = false;
482
+ clearButton.disabled = options.enableSelection === false;
483
+ context?.onProgressiveRender?.();
484
+ return { $el: root, unmount, fitToModel, select };
485
+ }
486
+ catch (error) {
487
+ const cancelled = context?.signal?.aborted || !target.contains(root);
488
+ const message = error instanceof Error ? error.message : String(error);
489
+ await unmount().catch(() => { });
490
+ if (!cancelled) {
491
+ root.dataset.ifcStatus = "error";
492
+ status.textContent = message;
493
+ status.hidden = false;
494
+ // Retain a helpful error surface without retaining any workers or WebGL resources.
495
+ stage.replaceChildren(status);
496
+ target.replaceChildren(style, root);
497
+ }
498
+ throw error;
499
+ }
500
+ }
@@ -0,0 +1,7 @@
1
+ /** Bounded data-only bridge. This module does not import the optional BIM engines. */
2
+ export type IfcSettings = Readonly<Record<string, unknown>>;
3
+ /** Copy before allocating a Worker or transferring input. No accessors are executed. */
4
+ export declare function copyIfcSettings(value: unknown, label?: string): Record<string, unknown>;
5
+ export declare function copyIfcImporterSettings(value: unknown): Record<string, unknown>;
6
+ /** Apply only existing public data fields, retaining library-owned Set/Map instances. */
7
+ export declare function applyIfcSettings(target: object, settings: IfcSettings): void;
@@ -0,0 +1,153 @@
1
+ const unsafe = (key) => key.startsWith("_") || ["constructor", "prototype"].includes(key);
2
+ const isRecord = (value) => value !== null &&
3
+ typeof value === "object" &&
4
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
5
+ /** Copy before allocating a Worker or transferring input. No accessors are executed. */
6
+ export function copyIfcSettings(value, label = "IFC settings") {
7
+ if (value === undefined)
8
+ return {};
9
+ if (!isRecord(value))
10
+ throw new TypeError(`${label} must be a plain record`);
11
+ let nodes = 0, characters = 0;
12
+ const active = new Set();
13
+ const copy = (input, depth) => {
14
+ if (++nodes > 2048 || depth > 8)
15
+ throw new RangeError(`${label} exceeds configuration limits`);
16
+ if (typeof input === "string") {
17
+ characters += input.length;
18
+ if (characters > 65536)
19
+ throw new RangeError(`${label} exceeds configuration limits`);
20
+ return input;
21
+ }
22
+ if (input === null || typeof input === "boolean")
23
+ return input;
24
+ if (typeof input === "number" && Number.isFinite(input))
25
+ return input;
26
+ if (!input || typeof input !== "object")
27
+ throw new TypeError(`${label} accepts data, not functions or undefined values`);
28
+ if (active.has(input))
29
+ throw new TypeError(`${label} must not contain cycles`);
30
+ active.add(input);
31
+ try {
32
+ if (Object.getPrototypeOf(input) === Set.prototype) {
33
+ if (Reflect.ownKeys(input).length)
34
+ throw new TypeError(`${label} collections must not have custom properties`);
35
+ if (input.size > 2048)
36
+ throw new RangeError(`${label} exceeds configuration limits`);
37
+ return new Set([...Set.prototype.values.call(input)].map((v) => copy(v, depth + 1)));
38
+ }
39
+ if (Object.getPrototypeOf(input) === Map.prototype) {
40
+ if (Reflect.ownKeys(input).length)
41
+ throw new TypeError(`${label} collections must not have custom properties`);
42
+ if (input.size > 2048)
43
+ throw new RangeError(`${label} exceeds configuration limits`);
44
+ return new Map([...Map.prototype.entries.call(input)].map(([k, v]) => {
45
+ if (typeof k !== "string" && typeof k !== "number")
46
+ throw new TypeError(`${label} Map keys must be strings or numbers`);
47
+ if (typeof k === "string" && unsafe(k))
48
+ throw new TypeError(`${label} contains a reserved key`);
49
+ return [copy(k, depth + 1), copy(v, depth + 1)];
50
+ }));
51
+ }
52
+ const array = Array.isArray(input) &&
53
+ Object.getPrototypeOf(input) === Array.prototype;
54
+ if (!array && !isRecord(input))
55
+ throw new TypeError(`${label} accepts only plain data, arrays, Sets and Maps`);
56
+ if (array && input.length > 2048)
57
+ throw new RangeError(`${label} exceeds configuration limits`);
58
+ const output = array ? [] : {};
59
+ for (const key of Reflect.ownKeys(input)) {
60
+ if (array && key === "length")
61
+ continue;
62
+ if (typeof key !== "string" || unsafe(key))
63
+ throw new TypeError(`${label} contains a reserved key`);
64
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
65
+ if (!("value" in descriptor))
66
+ throw new TypeError(`${label} must not contain accessors`);
67
+ if (!descriptor.enumerable)
68
+ throw new TypeError(`${label} must contain enumerable data only`);
69
+ if (array && !/^(0|[1-9]\d*)$/.test(key))
70
+ throw new TypeError(`${label} contains an invalid array key`);
71
+ characters += key.length;
72
+ if (characters > 65536)
73
+ throw new RangeError(`${label} exceeds configuration limits`);
74
+ Object.defineProperty(output, key, {
75
+ value: copy(descriptor.value, depth + 1),
76
+ enumerable: true,
77
+ writable: true,
78
+ configurable: true,
79
+ });
80
+ }
81
+ if (array && input.length !== output.length)
82
+ throw new TypeError(`${label} must not contain sparse trailing arrays`);
83
+ return output;
84
+ }
85
+ finally {
86
+ active.delete(input);
87
+ }
88
+ };
89
+ return copy(value, 0);
90
+ }
91
+ export function copyIfcImporterSettings(value) {
92
+ const result = copyIfcSettings(value, "IFC importer settings");
93
+ for (const key of Object.keys(result)) {
94
+ if (["wasm", "webIfc", "worker", "workerUrl", "process", "dispose"].includes(key))
95
+ throw new TypeError(`IFC importer setting is adapter-owned: ${key}`);
96
+ }
97
+ return result;
98
+ }
99
+ /** Apply only existing public data fields, retaining library-owned Set/Map instances. */
100
+ export function applyIfcSettings(target, settings) {
101
+ const merge = (old, next, path) => {
102
+ if (old instanceof Set) {
103
+ if (!(next instanceof Set))
104
+ throw new TypeError(`${path} requires a Set`);
105
+ old.clear();
106
+ for (const value of next)
107
+ old.add(value);
108
+ return old;
109
+ }
110
+ if (old instanceof Map) {
111
+ if (!(next instanceof Map))
112
+ throw new TypeError(`${path} requires a Map`);
113
+ old.clear();
114
+ for (const [key, value] of next)
115
+ old.set(key, value);
116
+ return old;
117
+ }
118
+ if (isRecord(old) && isRecord(next)) {
119
+ // Loader/geometry bags accept new upstream fields; collections stay library-owned.
120
+ for (const [key, value] of Object.entries(next)) {
121
+ if (unsafe(key))
122
+ throw new TypeError(`Reserved IFC setting: ${path}.${key}`);
123
+ const descriptor = Object.getOwnPropertyDescriptor(old, key);
124
+ if (descriptor &&
125
+ (!("value" in descriptor) || typeof descriptor.value === "function"))
126
+ throw new TypeError(`Executable IFC setting: ${path}.${key}`);
127
+ Object.defineProperty(old, key, {
128
+ value: descriptor
129
+ ? merge(descriptor.value, value, `${path}.${key}`)
130
+ : value,
131
+ enumerable: true,
132
+ writable: true,
133
+ configurable: true,
134
+ });
135
+ }
136
+ return old;
137
+ }
138
+ if (old !== null &&
139
+ (typeof old !== typeof next || typeof old === "function"))
140
+ throw new TypeError(`Incompatible IFC setting: ${path}`);
141
+ return next;
142
+ };
143
+ for (const [key, value] of Object.entries(settings)) {
144
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
145
+ if (unsafe(key) ||
146
+ !descriptor ||
147
+ !("value" in descriptor) ||
148
+ !descriptor.writable ||
149
+ typeof descriptor.value === "function")
150
+ throw new TypeError(`Unknown or non-data IFC setting: ${key}`);
151
+ target[key] = merge(descriptor.value, value, key);
152
+ }
153
+ }
@@ -0,0 +1,18 @@
1
+ Permission is hereby granted, free of charge, to any person obtaining
2
+ a copy of this software and associated documentation files (the
3
+ "Software"), to deal in the Software without restriction, including
4
+ without limitation the rights to use, copy, modify, merge, publish,
5
+ distribute, sublicense, and/or sell copies of the Software, and to
6
+ permit persons to whom the Software is furnished to do so, subject to
7
+ the following conditions:
8
+
9
+ The above copyright notice and this permission notice shall be
10
+ included in all copies or substantial portions of the Software.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
13
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
14
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
15
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
16
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
17
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
18
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@file-viewer/renderer-3d",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone 3D model renderer plugin for File Viewer powered by Three.js loaders and OrbitControls.",
@@ -48,26 +48,63 @@
48
48
  "import": "./dist/index.js",
49
49
  "default": "./dist/index.js"
50
50
  },
51
- "./package.json": "./package.json"
51
+ "./package.json": "./package.json",
52
+ "./ifc": {
53
+ "types": "./dist/ifc.d.ts",
54
+ "import": "./dist/ifc.js",
55
+ "default": "./dist/ifc.js"
56
+ }
52
57
  },
53
58
  "files": [
54
59
  "dist",
55
60
  "README.md",
56
61
  "README.en.md",
57
- "LICENSE"
62
+ "LICENSE",
63
+ "bin",
64
+ "IFC.md",
65
+ "licenses"
58
66
  ],
59
67
  "dependencies": {
60
- "@file-viewer/core": "3.0.2",
61
- "@file-viewer/geometry-engine": "3.0.2",
62
- "three": "^0.185.1"
68
+ "@file-viewer/core": "3.1.0",
69
+ "@file-viewer/geometry-engine": "3.1.0",
70
+ "three": "^0.186.0"
63
71
  },
64
72
  "devDependencies": {
65
73
  "@types/three": "^0.185.4",
66
- "typescript": "^6.0.3"
74
+ "typescript": "^6.0.3",
75
+ "@thatopen/components": "3.4.8",
76
+ "@thatopen/fragments": "3.4.7",
77
+ "web-ifc": "0.0.77",
78
+ "esbuild": "^0.28.2"
67
79
  },
68
80
  "license": "Apache-2.0",
81
+ "bin": {
82
+ "file-viewer-ifc-assets": "./bin/copy-ifc-assets.mjs"
83
+ },
84
+ "peerDependencies": {
85
+ "@thatopen/components": "3.4.8",
86
+ "@thatopen/fragments": "3.4.7",
87
+ "web-ifc": "0.0.77",
88
+ "esbuild": "^0.28.2"
89
+ },
90
+ "peerDependenciesMeta": {
91
+ "@thatopen/components": {
92
+ "optional": true
93
+ },
94
+ "@thatopen/fragments": {
95
+ "optional": true
96
+ },
97
+ "web-ifc": {
98
+ "optional": true
99
+ },
100
+ "esbuild": {
101
+ "optional": true
102
+ }
103
+ },
69
104
  "scripts": {
70
105
  "build": "tsc -b tsconfig.json",
71
- "type-check": "tsc -b tsconfig.json"
106
+ "type-check": "tsc -b tsconfig.json",
107
+ "verify:ifc": "node scripts/verify-ifc-entry.mjs && node --test scripts/ifc-settings.test.mjs",
108
+ "verify:ifc-browser": "node scripts/verify-ifc-browser.mjs"
72
109
  }
73
110
  }