@sequent-org/ifc-viewer 1.2.4-ci.45.0 → 1.2.4-ci.47.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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * ModelLoaderRegistry
3
+ * Central registry for model loaders (IFC/FBX/...) with a stable API.
4
+ *
5
+ * Design goals:
6
+ * - Open/Closed: add new formats by registering a loader, without touching UI code.
7
+ * - Single Responsibility: registry selects loader + orchestrates loading + optional viewer integration.
8
+ *
9
+ * Loader contract (duck-typing, documented via JSDoc):
10
+ * - loader.id: string (e.g. "ifc", "fbx")
11
+ * - loader.extensions: string[] (lowercase, with leading dot, e.g. [".ifc",".ifczip"])
12
+ * - loader.associatedExtensions?: string[] (optional; affects accept= only, not loader selection)
13
+ * - loader.loadFile(file, ctx): Promise<LoadResult>
14
+ * - loader.loadFiles?(files, ctx): Promise<LoadResult> (optional, for multi-file cases like OBJ+MTL)
15
+ * - loader.loadUrl(url, ctx): Promise<LoadResult>
16
+ *
17
+ * LoadResult:
18
+ * - object3D: THREE.Object3D (required)
19
+ * - format: string (loader id)
20
+ * - name: string (file name or URL)
21
+ * - capabilities?: object (optional, format-specific)
22
+ * - replacedInViewer?: boolean (optional, true if loader already called viewer.replaceWithModel)
23
+ */
24
+ export class ModelLoaderRegistry {
25
+ constructor() {
26
+ /** @type {Array<any>} */
27
+ this._loaders = [];
28
+ }
29
+
30
+ /**
31
+ * @param {any} loader
32
+ * @returns {ModelLoaderRegistry}
33
+ */
34
+ register(loader) {
35
+ if (!loader || typeof loader !== 'object') {
36
+ throw new Error('ModelLoaderRegistry.register: loader must be an object');
37
+ }
38
+ if (!loader.id || typeof loader.id !== 'string') {
39
+ throw new Error('ModelLoaderRegistry.register: loader.id must be a string');
40
+ }
41
+ if (!Array.isArray(loader.extensions) || loader.extensions.some((x) => typeof x !== 'string')) {
42
+ throw new Error(`ModelLoaderRegistry.register: loader.extensions must be string[] (${loader.id})`);
43
+ }
44
+ if (typeof loader.loadFile !== 'function' && typeof loader.loadUrl !== 'function') {
45
+ throw new Error(`ModelLoaderRegistry.register: loader must implement loadFile and/or loadUrl (${loader.id})`);
46
+ }
47
+ this._loaders.push(loader);
48
+ return this;
49
+ }
50
+
51
+ /**
52
+ * @returns {string[]} unique extensions (lowercase) like [".ifc",".fbx"]
53
+ */
54
+ getAllExtensions() {
55
+ const out = new Set();
56
+ for (const l of this._loaders) {
57
+ for (const ext of (l.extensions || [])) out.add(String(ext).toLowerCase());
58
+ for (const ext of (l.associatedExtensions || [])) out.add(String(ext).toLowerCase());
59
+ }
60
+ return Array.from(out).sort();
61
+ }
62
+
63
+ /**
64
+ * @returns {string} accept string for <input type="file" accept="...">
65
+ */
66
+ getAcceptString() {
67
+ const exts = this.getAllExtensions();
68
+ return exts.length ? exts.join(',') : '';
69
+ }
70
+
71
+ /**
72
+ * @param {string} nameOrUrl
73
+ * @returns {any|null} loader
74
+ */
75
+ getLoaderForName(nameOrUrl) {
76
+ const s = String(nameOrUrl || '').toLowerCase();
77
+ if (!s) return null;
78
+ // Prefer longest extension match (e.g. ".ifczip" over ".zip")
79
+ let best = null;
80
+ let bestLen = -1;
81
+ for (const l of this._loaders) {
82
+ for (const ext of (l.extensions || [])) {
83
+ const e = String(ext).toLowerCase();
84
+ if (!e || !e.startsWith('.')) continue;
85
+ if (s.endsWith(e) && e.length > bestLen) {
86
+ best = l;
87
+ bestLen = e.length;
88
+ }
89
+ }
90
+ }
91
+ return best;
92
+ }
93
+
94
+ /**
95
+ * Loads a model from File and (optionally) integrates into viewer.
96
+ *
97
+ * @param {File} file
98
+ * @param {{ viewer?: any, logger?: any }} [ctx]
99
+ * @returns {Promise<any|null>} LoadResult or null on error
100
+ */
101
+ async loadFile(file, ctx = {}) {
102
+ const name = file?.name || '';
103
+ const loader = this.getLoaderForName(name);
104
+ if (!loader) {
105
+ throw new Error(`Формат не поддерживается: ${name || 'unknown file'}`);
106
+ }
107
+
108
+ const logger = ctx?.logger || console;
109
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
110
+
111
+ try {
112
+ logger?.log?.('[ModelLoaderRegistry] loadFile', { name, loader: loader.id });
113
+ const result = await loader.loadFile(file, ctx);
114
+ this._validateResult(result, loader.id);
115
+
116
+ this._maybeReplaceInViewer(result, ctx);
117
+ this._logResultSummary(result, t0, logger);
118
+ return result;
119
+ } catch (e) {
120
+ logger?.error?.('[ModelLoaderRegistry] loadFile error', { name, loader: loader.id, error: e });
121
+ throw e;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Loads a model from multiple selected files (e.g. .obj + .mtl + textures).
127
+ *
128
+ * Rules:
129
+ * - Choose ONE "primary" model file among the selection by best extension match.
130
+ * - Pass all selected files to loader via ctx.files.
131
+ * - If the chosen loader supports loadFiles(), it will be used; otherwise falls back to loadFile(primary).
132
+ *
133
+ * @param {File[]|FileList} files
134
+ * @param {{ viewer?: any, logger?: any }} [ctx]
135
+ */
136
+ async loadFiles(files, ctx = {}) {
137
+ const arr = Array.from(files || []).filter(Boolean);
138
+ const logger = ctx?.logger || console;
139
+ if (!arr.length) throw new Error('Нет выбранных файлов');
140
+
141
+ // Find best primary file among selection
142
+ let best = null;
143
+ let bestLoader = null;
144
+ let bestLen = -1;
145
+
146
+ for (const f of arr) {
147
+ const name = f?.name || '';
148
+ const l = this.getLoaderForName(name);
149
+ if (!l) continue;
150
+ // score: longest extension match
151
+ const lower = name.toLowerCase();
152
+ for (const ext of (l.extensions || [])) {
153
+ const e = String(ext).toLowerCase();
154
+ if (e && lower.endsWith(e) && e.length > bestLen) {
155
+ best = f;
156
+ bestLoader = l;
157
+ bestLen = e.length;
158
+ }
159
+ }
160
+ }
161
+
162
+ if (!best || !bestLoader) {
163
+ throw new Error(`Формат не поддерживается: ${arr.map((f) => f?.name).filter(Boolean).join(', ')}`);
164
+ }
165
+
166
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
167
+ const names = arr.map((f) => f?.name).filter(Boolean);
168
+
169
+ try {
170
+ logger?.log?.('[ModelLoaderRegistry] loadFiles', { primary: best.name, loader: bestLoader.id, files: names });
171
+ const nextCtx = { ...ctx, files: arr };
172
+ const result = (typeof bestLoader.loadFiles === 'function')
173
+ ? await bestLoader.loadFiles(arr, nextCtx)
174
+ : await bestLoader.loadFile(best, nextCtx);
175
+
176
+ this._validateResult(result, bestLoader.id);
177
+ this._maybeReplaceInViewer(result, ctx);
178
+ this._logResultSummary(result, t0, logger);
179
+ return result;
180
+ } catch (e) {
181
+ logger?.error?.('[ModelLoaderRegistry] loadFiles error', { primary: best.name, loader: bestLoader.id, files: names, error: e });
182
+ throw e;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Loads a model from URL and (optionally) integrates into viewer.
188
+ *
189
+ * @param {string} url
190
+ * @param {{ viewer?: any, logger?: any }} [ctx]
191
+ * @returns {Promise<any|null>} LoadResult or null on error
192
+ */
193
+ async loadUrl(url, ctx = {}) {
194
+ const loader = this.getLoaderForName(url);
195
+ if (!loader) {
196
+ throw new Error(`Формат не поддерживается: ${url || 'unknown url'}`);
197
+ }
198
+
199
+ const logger = ctx?.logger || console;
200
+ const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
201
+
202
+ try {
203
+ logger?.log?.('[ModelLoaderRegistry] loadUrl', { url, loader: loader.id });
204
+ const result = await loader.loadUrl(url, ctx);
205
+ this._validateResult(result, loader.id);
206
+
207
+ this._maybeReplaceInViewer(result, ctx);
208
+ this._logResultSummary(result, t0, logger);
209
+ return result;
210
+ } catch (e) {
211
+ logger?.error?.('[ModelLoaderRegistry] loadUrl error', { url, loader: loader.id, error: e });
212
+ throw e;
213
+ }
214
+ }
215
+
216
+ _validateResult(result, loaderId) {
217
+ if (!result || typeof result !== 'object') {
218
+ throw new Error(`Loader "${loaderId}" returned invalid result`);
219
+ }
220
+ if (!result.object3D) {
221
+ throw new Error(`Loader "${loaderId}" returned result without object3D`);
222
+ }
223
+ if (!result.format) result.format = loaderId;
224
+ if (!result.name) result.name = '';
225
+ }
226
+
227
+ _maybeReplaceInViewer(result, ctx) {
228
+ if (result?.replacedInViewer) return;
229
+ const viewer = ctx?.viewer;
230
+ if (!viewer || typeof viewer.replaceWithModel !== 'function') return;
231
+ try {
232
+ viewer.replaceWithModel(result.object3D);
233
+ } catch (e) {
234
+ const logger = ctx?.logger || console;
235
+ logger?.warn?.('[ModelLoaderRegistry] viewer.replaceWithModel failed', e);
236
+ }
237
+ }
238
+
239
+ _logResultSummary(result, t0, logger) {
240
+ const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
241
+ const ms = Math.round((t1 - t0) * 10) / 10;
242
+ const obj = result?.object3D;
243
+ const summary = {
244
+ format: result?.format,
245
+ name: result?.name,
246
+ ms,
247
+ object3D: obj ? { type: obj.type, children: Array.isArray(obj.children) ? obj.children.length : undefined } : null,
248
+ };
249
+ logger?.log?.('[ModelLoaderRegistry] loaded', summary);
250
+ }
251
+ }
252
+
@@ -0,0 +1,275 @@
1
+ import { Box3, LoadingManager, Vector3 } from "three";
2
+ import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
3
+
4
+ /**
5
+ * COLLADA (.dae) loader with optional textures via multi-file selection.
6
+ *
7
+ * Multi-file behavior:
8
+ * - User may select:
9
+ * - only .dae -> load scene, external images may be missing
10
+ * - .dae + images -> resolve images by basename via LoadingManager URL modifier
11
+ *
12
+ * Notes:
13
+ * - ColladaLoader typically handles up-axis conversion based on <asset><up_axis>.
14
+ */
15
+ export class DaeModelLoader {
16
+ /**
17
+ * @param {{ rotateXNeg90?: boolean, alignToGround?: boolean }} [options]
18
+ * rotateXNeg90:
19
+ * - Some DAE assets are effectively Z-up; viewer is Y-up. This rotates into Y-up.
20
+ * alignToGround:
21
+ * - Moves model so its bbox.min.y becomes ~0 (helps shadow receiver positioning).
22
+ */
23
+ constructor(options = {}) {
24
+ this.id = 'dae';
25
+ this.extensions = ['.dae'];
26
+ this.associatedExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif', '.tga'];
27
+ this._loader = null;
28
+ this._rotateXNeg90 = options.rotateXNeg90 !== false; // default true
29
+ this._alignToGround = options.alignToGround !== false; // default true
30
+ }
31
+
32
+ async loadFile(file, ctx) {
33
+ return await this.loadFiles([file], ctx);
34
+ }
35
+
36
+ /**
37
+ * @param {File[]|FileList} files
38
+ * @param {any} ctx
39
+ */
40
+ async loadFiles(files, ctx) {
41
+ const logger = ctx?.logger || console;
42
+ const arr = Array.from(files || []).filter(Boolean);
43
+ if (!arr.length) throw new Error('DaeModelLoader: no files');
44
+
45
+ const daeFile = arr.find((f) => this._isExt(f?.name, '.dae'));
46
+ if (!daeFile) {
47
+ throw new Error(`DaeModelLoader: .dae not found in selection: ${arr.map((f) => f?.name).filter(Boolean).join(', ')}`);
48
+ }
49
+
50
+ const fileMap = this._buildFileMap(arr);
51
+ const urlMap = new Map();
52
+ const revokeAll = () => {
53
+ try { for (const u of urlMap.values()) URL.revokeObjectURL(u); } catch (_) {}
54
+ };
55
+ const getBlobUrl = (key) => {
56
+ const k = String(key || '').toLowerCase();
57
+ if (!k) return null;
58
+ if (urlMap.has(k)) return urlMap.get(k);
59
+ const f = fileMap.get(k);
60
+ if (!f) return null;
61
+ const u = URL.createObjectURL(f);
62
+ urlMap.set(k, u);
63
+ return u;
64
+ };
65
+
66
+ const manager = new LoadingManager();
67
+ /** @type {Set<string>} */
68
+ const failedUrls = new Set();
69
+ /** @type {Set<string>} */
70
+ const requestedBasenames = new Set();
71
+
72
+ manager.onError = (url) => {
73
+ try {
74
+ const raw = String(url || '');
75
+ const clean = raw.split('#')[0].split('?')[0];
76
+ const parts = clean.replace(/\\/g, '/').split('/');
77
+ const last = (parts[parts.length - 1] || '').trim();
78
+ if (last) failedUrls.add(last);
79
+ } catch (_) {}
80
+ };
81
+
82
+ manager.setURLModifier((url) => {
83
+ try {
84
+ const raw = String(url || '');
85
+ const clean = raw.split('#')[0].split('?')[0];
86
+ const parts = clean.replace(/\\/g, '/').split('/');
87
+ const last = (parts[parts.length - 1] || '').trim();
88
+ if (!last) return raw;
89
+ requestedBasenames.add(last);
90
+ const blob = getBlobUrl(last);
91
+ return blob || raw;
92
+ } catch (_) {
93
+ return url;
94
+ }
95
+ });
96
+
97
+ try {
98
+ logger?.log?.('[DaeModelLoader] loadFiles', { dae: daeFile.name, files: arr.map((f) => f?.name).filter(Boolean) });
99
+ const daeText = await daeFile.text();
100
+
101
+ // Diagnostics: inspect raw DAE for image references and textures
102
+ try {
103
+ const diag = this._diagnoseDaeText(daeText);
104
+ // eslint-disable-next-line no-console
105
+ logger?.log?.('[DaeModelLoader] dae diagnostics', diag);
106
+ } catch (e) {
107
+ logger?.warn?.('[DaeModelLoader] dae diagnostics failed', e);
108
+ }
109
+
110
+ const loader = new ColladaLoader(manager);
111
+ const collada = loader.parse(daeText, '');
112
+
113
+ // Wait for async texture loads (if any) before revoking blob URLs.
114
+ await this._waitManagerIdle(manager, 2500);
115
+
116
+ const scene = collada?.scene;
117
+ if (!scene) throw new Error('DaeModelLoader: parsed without scene');
118
+ try { scene.updateMatrixWorld?.(true); } catch (_) {}
119
+
120
+ // Axis + grounding BEFORE Viewer.replaceWithModel(): ensures bbox/shadowReceiver computed correctly.
121
+ try {
122
+ if (this._rotateXNeg90) {
123
+ scene.rotation.x = -Math.PI / 2;
124
+ scene.updateMatrixWorld?.(true);
125
+ }
126
+ if (this._alignToGround) {
127
+ const box = new Box3().setFromObject(scene);
128
+ const minY = box.min.y;
129
+ if (Number.isFinite(minY)) {
130
+ // Bring model to "floor" level
131
+ scene.position.y -= minY;
132
+ // Tiny epsilon to avoid z-fighting with receiver
133
+ scene.position.y += 0.001;
134
+ scene.updateMatrixWorld?.(true);
135
+ }
136
+ }
137
+ } catch (_) {}
138
+
139
+ const missingAll = Array.from(new Set([...Array.from(failedUrls.values())]));
140
+ if (missingAll.length) {
141
+ logger?.warn?.('[DaeModelLoader] missing assets (select these files too):', missingAll);
142
+ } else if (requestedBasenames.size) {
143
+ // If images referenced but none failed, still helpful to log what was requested
144
+ logger?.log?.('[DaeModelLoader] referenced assets', { requested: Array.from(requestedBasenames.values()).slice(0, 30) });
145
+ }
146
+
147
+ // Diagnostics: meshes/materials
148
+ try {
149
+ let meshes = 0;
150
+ let mats = 0;
151
+ let withMap = 0;
152
+ scene.traverse?.((n) => {
153
+ if (!n?.isMesh) return;
154
+ meshes++;
155
+ const m = n.material;
156
+ const arrM = Array.isArray(m) ? m : [m];
157
+ for (const mi of arrM) {
158
+ if (!mi) continue;
159
+ mats++;
160
+ if (mi.map) withMap++;
161
+ }
162
+ });
163
+ // BBox after axis/grounding for diagnostics
164
+ let bbox = null;
165
+ try {
166
+ const b = new Box3().setFromObject(scene);
167
+ const size = b.getSize(new Vector3());
168
+ const center = b.getCenter(new Vector3());
169
+ bbox = {
170
+ size: { x: +size.x.toFixed(3), y: +size.y.toFixed(3), z: +size.z.toFixed(3) },
171
+ center: { x: +center.x.toFixed(3), y: +center.y.toFixed(3), z: +center.z.toFixed(3) },
172
+ minY: +b.min.y.toFixed(3),
173
+ };
174
+ } catch (_) {}
175
+ logger?.log?.('[DaeModelLoader] parsed', {
176
+ dae: daeFile.name,
177
+ meshes,
178
+ materials: mats,
179
+ withMap,
180
+ animations: collada?.animations?.length || 0,
181
+ bbox,
182
+ axisFix: { rotateXNeg90: this._rotateXNeg90, alignToGround: this._alignToGround },
183
+ });
184
+ } catch (_) {}
185
+
186
+ return {
187
+ object3D: scene,
188
+ format: this.id,
189
+ name: daeFile.name,
190
+ replacedInViewer: false,
191
+ capabilities: { kind: 'generic', missingAssets: missingAll, animations: collada?.animations || [] },
192
+ };
193
+ } finally {
194
+ revokeAll();
195
+ }
196
+ }
197
+
198
+ _isExt(name, ext) {
199
+ const n = String(name || '').toLowerCase();
200
+ return n.endsWith(String(ext).toLowerCase());
201
+ }
202
+
203
+ _buildFileMap(arr) {
204
+ const map = new Map();
205
+ for (const f of arr) {
206
+ const full = String(f?.name || '');
207
+ if (!full) continue;
208
+ const base = (full.split(/[/\\]/).pop() || full).toLowerCase();
209
+ if (!map.has(base)) map.set(base, f);
210
+ }
211
+ return map;
212
+ }
213
+
214
+ _waitManagerIdle(manager, timeoutMs = 2000) {
215
+ return new Promise((resolve) => {
216
+ let done = false;
217
+ const finish = () => {
218
+ if (done) return;
219
+ done = true;
220
+ resolve();
221
+ };
222
+ try {
223
+ const prevOnLoad = manager.onLoad;
224
+ manager.onLoad = () => {
225
+ try { prevOnLoad?.(); } catch (_) {}
226
+ finish();
227
+ };
228
+ } catch (_) {}
229
+ setTimeout(finish, Math.max(0, Number(timeoutMs) || 0));
230
+ });
231
+ }
232
+
233
+ _diagnoseDaeText(daeText) {
234
+ const text = String(daeText || '');
235
+ // Up-axis (if present)
236
+ const upAxisMatch = text.match(/<up_axis>\s*([^<]+)\s*<\/up_axis>/i);
237
+ const upAxis = upAxisMatch ? upAxisMatch[1].trim() : null;
238
+
239
+ // <init_from> values inside library_images
240
+ const initFrom = [];
241
+ const reInit = /<init_from>\s*([^<]+?)\s*<\/init_from>/gi;
242
+ let m;
243
+ while ((m = reInit.exec(text)) !== null) {
244
+ const raw = (m[1] || '').trim();
245
+ if (raw) initFrom.push(raw);
246
+ if (initFrom.length >= 200) break; // cap
247
+ }
248
+
249
+ // Extract basenames and file extensions (for comparing to selected files)
250
+ const basenames = initFrom.map((p) => (p.replace(/\\/g, '/').split('/').pop() || p).trim()).filter(Boolean);
251
+ const jpgs = basenames.filter((b) => /\.(jpe?g)$/i.test(b));
252
+ const pngs = basenames.filter((b) => /\.png$/i.test(b));
253
+
254
+ // Look for <texture ...> occurrences (typical COLLADA material binding)
255
+ const texTags = [];
256
+ const reTex = /<texture\b[^>]*>/gi;
257
+ while ((m = reTex.exec(text)) !== null) {
258
+ texTags.push(m[0]);
259
+ if (texTags.length >= 50) break;
260
+ }
261
+
262
+ return {
263
+ upAxis,
264
+ initFromCount: initFrom.length,
265
+ initFromSample: initFrom.slice(0, 12),
266
+ imageBasenameCount: basenames.length,
267
+ imageBasenameSample: basenames.slice(0, 12),
268
+ jpgCount: jpgs.length,
269
+ pngCount: pngs.length,
270
+ textureTagCount: texTags.length,
271
+ textureTagSample: texTags.slice(0, 6),
272
+ };
273
+ }
274
+ }
275
+
@@ -0,0 +1,68 @@
1
+ import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
2
+
3
+ /**
4
+ * FBX loader.
5
+ *
6
+ * Implementation detail:
7
+ * - For File: uses FBXLoader.parse(ArrayBuffer, path).
8
+ * - For URL: uses FBXLoader.loadAsync(url).
9
+ *
10
+ * Known limitations to be aware of (diagnose via logs):
11
+ * - External texture references near a local File are not resolved automatically.
12
+ */
13
+ export class FbxModelLoader {
14
+ constructor() {
15
+ this.id = 'fbx';
16
+ this.extensions = ['.fbx'];
17
+ this._loader = new FBXLoader();
18
+ }
19
+
20
+ /**
21
+ * @param {File} file
22
+ * @param {any} ctx
23
+ */
24
+ async loadFile(file, ctx) {
25
+ const logger = ctx?.logger || console;
26
+ const name = file?.name || '';
27
+ logger?.log?.('[FbxModelLoader] loadFile', { name, size: file?.size });
28
+
29
+ const buf = await file.arrayBuffer();
30
+ const obj = this._loader.parse(buf, '');
31
+ // Useful diagnostics: count meshes quickly
32
+ try {
33
+ let meshes = 0;
34
+ obj.traverse?.((n) => { if (n?.isMesh) meshes++; });
35
+ logger?.log?.('[FbxModelLoader] parsed', { name, type: obj?.type, meshes });
36
+ } catch (_) {}
37
+
38
+ return {
39
+ object3D: obj,
40
+ format: this.id,
41
+ name,
42
+ replacedInViewer: false,
43
+ capabilities: {
44
+ kind: 'generic',
45
+ },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * @param {string} url
51
+ * @param {any} ctx
52
+ */
53
+ async loadUrl(url, ctx) {
54
+ const logger = ctx?.logger || console;
55
+ logger?.log?.('[FbxModelLoader] loadUrl', { url });
56
+ const obj = await this._loader.loadAsync(url);
57
+ return {
58
+ object3D: obj,
59
+ format: this.id,
60
+ name: String(url || ''),
61
+ replacedInViewer: false,
62
+ capabilities: {
63
+ kind: 'generic',
64
+ },
65
+ };
66
+ }
67
+ }
68
+