@openbim/ifcx 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -5,9 +5,16 @@ GLB, from JavaScript and TypeScript. A WebAssembly build of the
5
5
  [`openbim-ifcx`](https://crates.io/crates/openbim-ifcx) Rust crate, from the
6
6
  `openbim-ifcx-wasm` crate in [openbimrs/ifcx](https://github.com/openbimrs/ifcx).
7
7
 
8
- Published to npm as `@openbim/ifcx`, a CommonJS build for Node 18 and later.
9
- Browser bundling works in principle (the crate builds for
10
- `wasm32-unknown-unknown`) but has no tested recipe yet.
8
+ Published to npm as `@openbim/ifcx`: one package for Node 18 and later,
9
+ bundlers (webpack, Rollup) and plain browser pages, with TypeScript
10
+ declarations for each. Every build is tested from the packed tarball,
11
+ the browser builds in headless Chrome.
12
+
13
+ Try it in the browser: the [viewer](https://openbimrs.github.io/ifcx/viewer/)
14
+ composes a file with this package and shows the GLB with three.js, on
15
+ desktop and on phones. The [documentation](https://openbimrs.github.io/ifcx/)
16
+ has a [JavaScript guide](https://openbimrs.github.io/ifcx/guide/javascript)
17
+ and the [API reference](https://openbimrs.github.io/ifcx/reference/crates/openbim-ifcx-wasm).
11
18
 
12
19
  Targets the `ifcx_alpha` draft of buildingSMART's IFC5. IFCX is still a
13
20
  moving draft; see the
@@ -19,12 +26,26 @@ moving draft; see the
19
26
  npm install @openbim/ifcx
20
27
  ```
21
28
 
22
- ## Example
29
+ ## Entry points
30
+
31
+ | Import | Build | Loads the wasm module |
32
+ | --- | --- | --- |
33
+ | `@openbim/ifcx` in Node (`require` or `import`) | CommonJS (`wasm-bindgen --target nodejs`) | synchronously, on load |
34
+ | `@openbim/ifcx` in a bundler | ES module (`--target bundler`) | through the bundler's WebAssembly support, e.g. webpack 5 `experiments.asyncWebAssembly` |
35
+ | `@openbim/ifcx/web` | ES module (`--target web`) | when you `await init()` |
36
+
37
+ `package.json` `exports` picks the build: the `node` condition gets the
38
+ CommonJS build, everything else the bundler build. Use `@openbim/ifcx/web`
39
+ for a page without a bundler, and for bundlers without WebAssembly ES
40
+ module integration, such as Vite (or add `vite-plugin-wasm`). All three
41
+ export the same API; the `web` build adds the default `init` export (and
42
+ `initSync`).
43
+
44
+ ## Node
23
45
 
24
46
  ```js
25
- const { readFileSync } = require("node:fs");
26
- const { writeFileSync } = require("node:fs");
27
- const { IfcxFile, compose, exportGlb } = require("@openbim/ifcx");
47
+ const { readFileSync, writeFileSync } = require("node:fs");
48
+ const { IfcxFile, compose, exportGlb } = require("@openbim/ifcx"); // or import
28
49
 
29
50
  const model = readFileSync("model.ifcx"); // a Buffer is a Uint8Array
30
51
  const file = IfcxFile.parse(model); // or a string
@@ -44,6 +65,39 @@ console.log(Object.keys(tree.children)); // the root nodes
44
65
  writeFileSync("model.glb", exportGlb([model, readFileSync("overlay.ifcx")]));
45
66
  ```
46
67
 
68
+ ## Browser
69
+
70
+ With a bundler, import the package as usual; the bundler loads the wasm
71
+ module:
72
+
73
+ ```js
74
+ import { compose, exportGlb, fetchImports } from "@openbim/ifcx";
75
+ ```
76
+
77
+ Without one, serve the package's `web/` directory, map the name in an
78
+ import map (or import the file by URL), and call `init()` once before
79
+ anything else. `init()` fetches `openbim_ifcx_wasm_bg.wasm` from next to
80
+ the module; pass `init({ module_or_path: url })` to load it from elsewhere.
81
+
82
+ ```html
83
+ <script type="importmap">
84
+ { "imports": { "@openbim/ifcx/web": "/node_modules/@openbim/ifcx/web/openbim_ifcx_wasm.js" } }
85
+ </script>
86
+ <script type="module">
87
+ import init, { IfcxFile, exportGlb, fetchImports, validate } from "@openbim/ifcx/web";
88
+
89
+ await init();
90
+ const url = new URL("models/house.ifcx", location.href);
91
+ const model = new Uint8Array(await (await fetch(url)).arrayBuffer());
92
+
93
+ // Fetch what the model imports, relative to its own URL, then use it.
94
+ const imports = await fetchImports(model, { baseUrl: url });
95
+ const report = validate(model, { imports });
96
+ const glb = exportGlb(model, { imports }); // e.g. for three.js's GLTFLoader.parse
97
+ console.log(IfcxFile.parse(model).header.id, report.valid, glb.length);
98
+ </script>
99
+ ```
100
+
47
101
  ## API
48
102
 
49
103
  | Call | Returns |
@@ -57,6 +111,8 @@ writeFileSync("model.glb", exportGlb([model, readFileSync("overlay.ifcx")]));
57
111
  | `compose(layers, options?)` | the composed tree as a `ComposedNode` |
58
112
  | `validate(layers, options?)` | a `ValidationReport` over all layers and resolved imports |
59
113
  | `exportGlb(layers, options?)` | the composed model as binary glTF 2.0, a `Uint8Array` |
114
+ | `fetchImports(layers, options?)` | a promise of a `Map` from import `uri` to file, for `options.imports` |
115
+ | `init(input?)` | `@openbim/ifcx/web` only: loads the wasm module; await it once first |
60
116
 
61
117
  `layers` is one input or an array of them, weakest first. TypeScript
62
118
  declarations ship with the package.
@@ -79,8 +135,31 @@ a file), the layers become the imports of a synthetic main layer, as
79
135
  upstream's `ifcx compose` command builds it, and every import must be
80
136
  supplied. `integrity` values are checked against the supplied bytes. In
81
137
  upstream order an import overrides the layer that imports it, and a later
82
- layer overrides both. Nothing is fetched from the network or the
83
- filesystem.
138
+ layer overrides both. The WebAssembly module never touches the network or
139
+ the filesystem.
140
+
141
+ **Fetching imports.** `fetchImports(layers, options?)` collects them for
142
+ you, in JavaScript: it reads each layer's `imports`, fetches every `uri`
143
+ (recursively, each once, concurrently) and resolves to a `Map` keyed by
144
+ the exact import `uri`, ready for `options.imports`.
145
+
146
+ - `baseUrl`: what relative URIs of the given layers resolve against;
147
+ `location.href` by default in a browser. A fetched file's own imports
148
+ resolve against its URL.
149
+ - `fetch`: the function that loads a URL, the global `fetch` by default.
150
+ Pass your own for credentials, a cache, a mirror, or Node files:
151
+ `{ fetch: async (url) => readFile(new URL(url)) }`. It may return a
152
+ `Response`, a `Uint8Array`, an `ArrayBuffer` or a string. Without a
153
+ `baseUrl` it receives a relative `uri` unchanged.
154
+ - `imports`: files already at hand, never fetched again.
155
+ - `signal`: an `AbortSignal` passed to every fetch.
156
+
157
+ The in-memory map keys a file by its exact `uri`, as upstream's
158
+ `InMemoryLayerProvider` does, so two different files imported under the
159
+ same relative `uri` from different directories cannot both be supplied;
160
+ the first one fetched is used. Anything that cannot be fetched rejects with
161
+ an `IfcxError` with code `fetch`; `integrity` and import cycles are checked
162
+ by `compose`, `validate` and `exportGlb`.
84
163
 
85
164
  **GLB export.** `exportGlb` writes the render scene of the composed tree:
86
165
  transformed, instanced meshes, lines and points with their materials, one
@@ -111,6 +190,7 @@ Every failure throws an `Error` with `name === "IfcxError"` and a stable
111
190
  | `compose` | a reference cycle, or a reference to a node no layer defines |
112
191
  | `invalid-argument` | an argument of the wrong type, or no layers |
113
192
  | `glb` | the scene could not be written as GLB (over 4 GiB) |
193
+ | `fetch` | `fetchImports` could not fetch an import, or got no file back |
114
194
 
115
195
  A code is never renamed or reused.
116
196
 
@@ -118,9 +198,17 @@ A code is never renamed or reused.
118
198
 
119
199
  ```sh
120
200
  cargo install wasm-bindgen-cli --version 0.2.128 --locked
121
- crates/openbim-ifcx-wasm/scripts/build-node-pkg.sh # builds pkg/ and runs the Node suite
201
+ crates/openbim-ifcx-wasm/scripts/build-npm-pkg.sh # builds pkg/, runs every check
122
202
  ```
123
203
 
204
+ The script binds one release build three times (`pkg/`, `pkg/bundler/`,
205
+ `pkg/web/`), runs the Node suite, then `npm pack`s the package and checks
206
+ each entry point from the tarball: Node `require` and `import`, a webpack
207
+ bundle, and the `web` and bundler builds in headless Chrome, which parse,
208
+ validate, compose, fetch imports and export GLB from the repository's
209
+ fixtures. It finds Chrome through `CHROME_BIN`, `PATH` or a Playwright
210
+ download; `IFCX_SKIP_BROWSER=1` skips the browser part with a warning.
211
+
124
212
  ## License
125
213
 
126
214
  MIT, like the rest of `openbimrs/ifcx`.
@@ -0,0 +1,53 @@
1
+ import type { IfcxInput } from "./openbim_ifcx_wasm.js";
2
+
3
+ /** The part of a `fetch` `Response` that `fetchImports` reads. */
4
+ export interface FetchedResponse {
5
+ ok?: boolean;
6
+ status?: number;
7
+ statusText?: string;
8
+ arrayBuffer(): Promise<ArrayBuffer>;
9
+ }
10
+
11
+ /**
12
+ * Loads one imported file. `fetch` itself fits; a custom function may also
13
+ * return the file directly. `url` is the import `uri` resolved against the
14
+ * importing file's URL (or `baseUrl`); when a relative `uri` has no base to
15
+ * resolve against, a custom function receives the `uri` unchanged.
16
+ */
17
+ export type ImportFetcher = (
18
+ url: string,
19
+ init?: { signal?: AbortSignal },
20
+ ) => Promise<FetchedResponse | IfcxInput | ArrayBuffer>;
21
+
22
+ /** Options for `fetchImports`. */
23
+ export interface FetchImportsOptions {
24
+ /**
25
+ * URL that relative import URIs of the given layers resolve against.
26
+ * Defaults to `location.href` in a browser. Imports of a fetched file
27
+ * resolve against that file's URL.
28
+ */
29
+ baseUrl?: string | URL;
30
+ /** Loads each file. Defaults to the global `fetch`. */
31
+ fetch?: ImportFetcher;
32
+ /** Files already at hand, keyed by import `uri`; never fetched again. */
33
+ imports?: Record<string, IfcxInput> | Map<string, IfcxInput>;
34
+ /** Passed to every fetch call. */
35
+ signal?: AbortSignal;
36
+ }
37
+
38
+ /**
39
+ * Fetch every file the layers import, recursively and each `uri` once, and
40
+ * return them keyed by the exact import `uri`: the `imports` option of
41
+ * `compose`, `validate` and `exportGlb`. Network access happens here, in
42
+ * JavaScript; the WebAssembly module never fetches anything. A file that
43
+ * cannot be fetched rejects with an `IfcxError` whose `code` is `"fetch"`.
44
+ *
45
+ * ```js
46
+ * const imports = await fetchImports(layers, { baseUrl: "https://example.org/model/" });
47
+ * const tree = compose(layers, { imports });
48
+ * ```
49
+ */
50
+ export function fetchImports(
51
+ layers: IfcxInput | IfcxInput[],
52
+ options?: FetchImportsOptions,
53
+ ): Promise<Map<string, IfcxInput>>;
@@ -0,0 +1,137 @@
1
+ // Import resolution over `fetch`, for the `imports` option of `compose`,
2
+ // `validate` and `exportGlb`.
3
+ //
4
+ // Plain JavaScript on purpose: the Rust crates perform no network access
5
+ // (ADR 0002), so the binding resolves imports in memory only and this
6
+ // module does the fetching on the JavaScript side. It reads each layer's
7
+ // `imports` list, fetches every named file (recursively, each URI once) and
8
+ // returns the files keyed by their exact import `uri`, which is what the
9
+ // in-memory `imports` option expects. Integrity values are checked later,
10
+ // by the binding, against the fetched bytes.
11
+ //
12
+ // This file is the ES module source. scripts/build-npm-pkg.sh copies it into
13
+ // the `web` and `bundler` builds and derives the CommonJS copy for the Node
14
+ // build from it, so it must stay self-contained: no imports, and every
15
+ // export a top-level `export async function` or `export function`.
16
+
17
+ /** The imports of an IFCX file, or none when it is not readable JSON. */
18
+ function importUris(input) {
19
+ let text = input;
20
+ if (typeof input !== "string") {
21
+ text = new TextDecoder().decode(input);
22
+ }
23
+ let file;
24
+ try {
25
+ file = JSON.parse(text);
26
+ } catch {
27
+ // Not JSON: the binding call that receives it reports where.
28
+ return [];
29
+ }
30
+ const imports = file !== null && typeof file === "object" ? file.imports : undefined;
31
+ if (!Array.isArray(imports)) return [];
32
+ return imports
33
+ .map((entry) => (entry !== null && typeof entry === "object" ? entry.uri : undefined))
34
+ .filter((uri) => typeof uri === "string");
35
+ }
36
+
37
+ function fetchError(message, cause) {
38
+ const error = new Error(message, cause === undefined ? undefined : { cause });
39
+ error.name = "IfcxError";
40
+ error.code = "fetch";
41
+ return error;
42
+ }
43
+
44
+ function isInput(value) {
45
+ return typeof value === "string" || value instanceof Uint8Array;
46
+ }
47
+
48
+ /** The URL `uri` names, relative to `base`; `undefined` if it cannot be resolved. */
49
+ function resolveUrl(uri, base) {
50
+ try {
51
+ return base === undefined ? new URL(uri).href : new URL(uri, base).href;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /** What a fetch function returned, as an IFCX input. */
58
+ async function readResponse(response, uri, url) {
59
+ if (isInput(response)) return response;
60
+ if (response instanceof ArrayBuffer) return new Uint8Array(response);
61
+ if (response !== null && typeof response === "object" && typeof response.arrayBuffer === "function") {
62
+ if (response.ok === false) {
63
+ const status = [response.status, response.statusText].filter(Boolean).join(" ");
64
+ throw fetchError(`could not fetch import ${JSON.stringify(uri)} from ${url}: HTTP ${status}`);
65
+ }
66
+ return new Uint8Array(await response.arrayBuffer());
67
+ }
68
+ throw fetchError(
69
+ `the fetch function returned neither a Response, a Uint8Array, an ArrayBuffer nor a string for ${JSON.stringify(uri)}`,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Fetch every file the layers import, recursively, keyed by the exact
75
+ * import `uri`, for the `imports` option of `compose`, `validate` and
76
+ * `exportGlb`.
77
+ */
78
+ export async function fetchImports(layers, options = {}) {
79
+ const list = Array.isArray(layers) ? layers : [layers];
80
+ for (const layer of list) {
81
+ if (!isInput(layer)) {
82
+ throw fetchError("every layer must be a string or a Uint8Array");
83
+ }
84
+ }
85
+ const fetcher = options.fetch ?? globalThis.fetch;
86
+ if (typeof fetcher !== "function") {
87
+ throw fetchError("no fetch function: pass options.fetch");
88
+ }
89
+ const custom = options.fetch !== undefined;
90
+ const base =
91
+ options.baseUrl !== undefined ? String(options.baseUrl) : globalThis.location?.href;
92
+ const init = options.signal === undefined ? undefined : { signal: options.signal };
93
+
94
+ const found = new Map();
95
+ if (options.imports !== undefined) {
96
+ const known = options.imports instanceof Map ? options.imports : Object.entries(options.imports);
97
+ for (const [uri, file] of known) found.set(uri, file);
98
+ }
99
+
100
+ // Breadth first; each level's fetches run concurrently. `url` is where a
101
+ // layer came from, so its relative imports resolve against it.
102
+ let level = list.map((file) => ({ file, url: base }));
103
+ for (const [, file] of found) level.push({ file, url: base });
104
+ while (level.length > 0) {
105
+ const pending = [];
106
+ for (const { file, url: importer } of level) {
107
+ for (const uri of importUris(file)) {
108
+ if (found.has(uri)) continue;
109
+ found.set(uri, undefined); // claimed: fetch each URI once
110
+ const url = resolveUrl(uri, importer);
111
+ if (url === undefined && !custom) {
112
+ throw fetchError(
113
+ `cannot resolve import ${JSON.stringify(uri)} to a URL: pass options.baseUrl`,
114
+ );
115
+ }
116
+ pending.push(
117
+ (async () => {
118
+ let response;
119
+ try {
120
+ response = await fetcher(url ?? uri, init);
121
+ } catch (error) {
122
+ throw fetchError(
123
+ `could not fetch import ${JSON.stringify(uri)} from ${url ?? uri}: ${error?.message ?? error}`,
124
+ error,
125
+ );
126
+ }
127
+ const fetched = await readResponse(response, uri, url ?? uri);
128
+ found.set(uri, fetched);
129
+ return { file: fetched, url: url ?? importer };
130
+ })(),
131
+ );
132
+ }
133
+ }
134
+ level = await Promise.all(pending);
135
+ }
136
+ return found;
137
+ }
@@ -0,0 +1,139 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /** An IFCX file as JSON text or as its UTF-8 bytes. */
5
+ export type IfcxInput = string | Uint8Array;
6
+
7
+ /** Options for `compose` and `validate`. */
8
+ export interface LayerOptions {
9
+ /**
10
+ * Files that imports name, keyed by the exact import `uri`. When given
11
+ * (even empty), imports are resolved as upstream does and every import
12
+ * must be here; without it, imports are not resolved.
13
+ */
14
+ imports?: Record<string, IfcxInput> | Map<string, IfcxInput>;
15
+ }
16
+
17
+ /** One node of a composed tree. The root has path `""`. */
18
+ export interface ComposedNode {
19
+ path: string;
20
+ attributes: Record<string, unknown>;
21
+ children: Record<string, ComposedNode>;
22
+ }
23
+
24
+ /** The `kind` of a validation failure. */
25
+ export type ValidationFailureKind =
26
+ | "missing-schema"
27
+ | "unknown-inherited-schema"
28
+ | "inheritance-cycle"
29
+ | "type-mismatch"
30
+ | "not-an-integer"
31
+ | "not-an-option"
32
+ | "missing-key"
33
+ | "too-few-elements"
34
+ | "too-many-elements"
35
+ | "missing-restrictions"
36
+ | "unknown-data-type"
37
+ | "other";
38
+
39
+ /** One attribute value that does not match its schema. */
40
+ export interface ValidationFailure {
41
+ /** Path of the node carrying the attribute. */
42
+ node: string;
43
+ /** Attribute id, which is also the schema id. */
44
+ attribute: string;
45
+ /** RFC 6901 JSON pointer into the value; `""` for the value itself. */
46
+ pointer: string;
47
+ kind: ValidationFailureKind;
48
+ message: string;
49
+ }
50
+
51
+ export interface ValidationReport {
52
+ valid: boolean;
53
+ failures: ValidationFailure[];
54
+ }
55
+
56
+ /**
57
+ * Compose layers, weakest first, into a tree of plain objects: the
58
+ * artificial root (path `""`) over every root node.
59
+ */
60
+ export function compose(layers: IfcxInput | IfcxInput[], options?: LayerOptions): ComposedNode;
61
+
62
+ /**
63
+ * Check the attributes of the layers, weakest first, against the schemas of
64
+ * every layer (and of resolved imports), after merging nodes that share a
65
+ * path.
66
+ */
67
+ export function validate(layers: IfcxInput | IfcxInput[], options?: LayerOptions): ValidationReport;
68
+
69
+ /** Options for `exportGlb`. */
70
+ export interface GlbOptions extends LayerOptions {
71
+ /** Rotate IFCX's Z-up axes to glTF's Y-up. Default `true`. */
72
+ yUp?: boolean;
73
+ /**
74
+ * Keep the model's coordinates by putting the scene origin on the root
75
+ * node. With `false` the model sits around the glTF origin, which is then
76
+ * only recorded in the root's `extras.ifcxOrigin`. Default `true`.
77
+ */
78
+ originOnRoot?: boolean;
79
+ }
80
+
81
+ /**
82
+ * Compose layers, weakest first, and write their render scene as a binary
83
+ * glTF 2.0 (`.glb`) file: transformed, instanced meshes, lines and points
84
+ * with their materials, one glTF node per instance named by its IFCX path.
85
+ */
86
+ export function exportGlb(layers: IfcxInput | IfcxInput[], options?: GlbOptions): Uint8Array;
87
+
88
+ /** The `code` of an `IfcxError`. */
89
+ export type IfcxErrorCode =
90
+ | "read"
91
+ | "write"
92
+ | "layer"
93
+ | "compose"
94
+ | "invalid-argument"
95
+ | "glb"
96
+ | "fetch";
97
+
98
+
99
+
100
+ /**
101
+ * One IFCX file. Writing it back is lossless: key order, unknown fields,
102
+ * `null` deletions and every number stay as read.
103
+ *
104
+ * A newtype because `wasm_bindgen` can only export a type defined in this
105
+ * crate; all state and behaviour are the core's.
106
+ */
107
+ export class IfcxFile {
108
+ private constructor();
109
+ free(): void;
110
+ [Symbol.dispose](): void;
111
+ /**
112
+ * Read an IFCX file from its JSON text or UTF-8 bytes.
113
+ */
114
+ static parse(input: IfcxInput): IfcxFile;
115
+ /**
116
+ * The file as a plain object, so `JSON.stringify(file)` works. Unlike
117
+ * `write`, a plain object may reorder integer-like keys and round
118
+ * integers beyond 2^53.
119
+ */
120
+ toJSON(): Record<string, unknown>;
121
+ /**
122
+ * Check every attribute against this file's own `schemas`. Imports are
123
+ * not resolved; use the `validate` function to include them.
124
+ */
125
+ validate(): ValidationReport;
126
+ /**
127
+ * The file as JSON text; `pretty` indents it by two spaces.
128
+ */
129
+ write(pretty?: boolean | null): string;
130
+ /**
131
+ * The `header` object.
132
+ */
133
+ readonly header: Record<string, unknown>;
134
+ /**
135
+ * Number of entries in `data`; several may share a path.
136
+ */
137
+ readonly nodeCount: number;
138
+ }
139
+ export * from "./fetch-imports.js";
@@ -0,0 +1,10 @@
1
+ /* @ts-self-types="./openbim_ifcx_wasm.d.ts" */
2
+ import * as wasm from "./openbim_ifcx_wasm_bg.wasm";
3
+ import { __wbg_set_wasm } from "./openbim_ifcx_wasm_bg.js";
4
+
5
+ __wbg_set_wasm(wasm);
6
+
7
+ export {
8
+ IfcxFile, compose, exportGlb, validate
9
+ } from "./openbim_ifcx_wasm_bg.js";
10
+ export { fetchImports } from "./fetch-imports.js";