brep-io-kernel 1.0.34 → 1.0.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brep-io-kernel",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
4
4
  "scripts": {
5
5
  "dev": "pnpm generateLicenses && pnpm build:kernel && vite --host 0.0.0.0",
6
6
  "build": "pnpm generateLicenses && vite build",
@@ -3,7 +3,8 @@
3
3
  // Designed to be embedded as an Accordion section (similar to expressionsManager).
4
4
  import * as THREE from 'three';
5
5
  import JSZip from 'jszip';
6
- import { generate3MF } from '../exporters/threeMF.js';
6
+ import { generate3MF, computeTriangleMaterialIndices } from '../exporters/threeMF.js';
7
+ import { CADmaterials } from './CADmaterials.js';
7
8
  import { localStorage as LS, STORAGE_BACKEND_EVENT } from '../idbStorage.js';
8
9
  import {
9
10
  listComponentRecords,
@@ -27,6 +28,9 @@ export class FileManagerWidget {
27
28
  this._iconsOnly = this._loadIconsPref();
28
29
  this._loadSeq = 0; // guards async load races
29
30
  this._thumbCache = new Map();
31
+ this._pendingGithubMeta = new Map();
32
+ this._saveOverlay = null;
33
+ this._saveLogEl = null;
30
34
  this._ensureStyles();
31
35
  this._buildUI();
32
36
  void this.refreshList();
@@ -115,8 +119,8 @@ export class FileManagerWidget {
115
119
  }));
116
120
  }
117
121
  // Fetch one model record
118
- async _getModel(name) {
119
- return await getComponentRecord(name);
122
+ async _getModel(name, options) {
123
+ return await getComponentRecord(name, options);
120
124
  }
121
125
  // Persist one model record
122
126
  async _setModel(name, dataObj) {
@@ -178,10 +182,66 @@ export class FileManagerWidget {
178
182
  .fm-item:hover { background: #0f172a; border-color: #334155; }
179
183
  .fm-item .fm-thumb { width: 60px; height: 60px; border: 1px solid #1f2937; background: #0b0e14; border-radius: 6px; }
180
184
  .fm-item .fm-del { position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; padding: 0; line-height: 1; }
185
+
186
+ /* Blocking save overlay */
187
+ .fm-save-overlay { position: fixed; inset: 0; background: rgba(2,6,23,0.65); display: flex; align-items: center; justify-content: center; z-index: 10050; }
188
+ .fm-save-panel { width: min(520px, 90vw); max-height: 80vh; background: #0b0e14; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 12px; padding: 16px 18px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
189
+ .fm-save-title { font-weight: 700; font-size: 14px; letter-spacing: .01em; margin-bottom: 10px; }
190
+ .fm-save-log { font-size: 12px; line-height: 1.4; max-height: 52vh; overflow: auto; white-space: pre-wrap; color: #cbd5f5; background: #0a0f1a; border: 1px solid #1f2937; border-radius: 8px; padding: 10px; }
191
+ .fm-save-line { margin-bottom: 6px; }
181
192
  `;
182
193
  document.head.appendChild(style);
183
194
  }
184
195
 
196
+ _setSaveBusy(isBusy) {
197
+ try {
198
+ if (this.saveBtn) this.saveBtn.disabled = !!isBusy;
199
+ if (this.nameInput) this.nameInput.disabled = !!isBusy;
200
+ } catch { /* ignore */ }
201
+ }
202
+
203
+ _startSaveProgress(title) {
204
+ try {
205
+ this._endSaveProgress();
206
+ const overlay = document.createElement('div');
207
+ overlay.className = 'fm-save-overlay';
208
+ const panel = document.createElement('div');
209
+ panel.className = 'fm-save-panel';
210
+ const header = document.createElement('div');
211
+ header.className = 'fm-save-title';
212
+ header.textContent = title || 'Saving...';
213
+ const log = document.createElement('div');
214
+ log.className = 'fm-save-log';
215
+ panel.appendChild(header);
216
+ panel.appendChild(log);
217
+ overlay.appendChild(panel);
218
+ document.body.appendChild(overlay);
219
+ this._saveOverlay = overlay;
220
+ this._saveLogEl = log;
221
+ } catch { /* ignore */ }
222
+ }
223
+
224
+ _logSaveProgress(message) {
225
+ try {
226
+ if (!this._saveLogEl) return;
227
+ const line = document.createElement('div');
228
+ line.className = 'fm-save-line';
229
+ line.textContent = message || '';
230
+ this._saveLogEl.appendChild(line);
231
+ this._saveLogEl.scrollTop = this._saveLogEl.scrollHeight;
232
+ } catch { /* ignore */ }
233
+ }
234
+
235
+ _endSaveProgress() {
236
+ try {
237
+ if (this._saveOverlay && this._saveOverlay.parentNode) {
238
+ this._saveOverlay.parentNode.removeChild(this._saveOverlay);
239
+ }
240
+ } catch { /* ignore */ }
241
+ this._saveOverlay = null;
242
+ this._saveLogEl = null;
243
+ }
244
+
185
245
  _buildUI() {
186
246
  // Header: name input + Save
187
247
  const header = document.createElement('div');
@@ -204,6 +264,7 @@ export class FileManagerWidget {
204
264
  saveBtn.textContent = 'Save';
205
265
  saveBtn.className = 'fm-btn';
206
266
  saveBtn.addEventListener('click', () => this.saveCurrent());
267
+ this.saveBtn = saveBtn;
207
268
  header.appendChild(saveBtn);
208
269
  this.uiElement.appendChild(header);
209
270
 
@@ -228,6 +289,24 @@ export class FileManagerWidget {
228
289
  this._refreshHistoryCollections('new-model');
229
290
  }
230
291
 
292
+ async _retryGithubOperation(action, op, progress) {
293
+ while (true) {
294
+ try {
295
+ const value = await op();
296
+ return { ok: true, value };
297
+ } catch (err) {
298
+ const msg = (err && typeof err.message === 'string' && err.message.trim())
299
+ ? err.message.trim()
300
+ : (err ? String(err) : '');
301
+ const details = msg ? `\n\n${msg}` : '';
302
+ try { if (typeof progress === 'function') progress(`${action} failed.${details}`); } catch { }
303
+ const retry = await window.confirm(`${action} failed.${details}\n\nRetry?`);
304
+ try { if (typeof progress === 'function') progress(retry ? 'Retrying...' : 'Save canceled by user.'); } catch { }
305
+ if (!retry) return { ok: false, error: err };
306
+ }
307
+ }
308
+ }
309
+
231
310
  async saveCurrent() {
232
311
  if (!this.viewer || !this.viewer.partHistory) return;
233
312
  let name = (this.nameInput.value || '').trim();
@@ -238,86 +317,203 @@ export class FileManagerWidget {
238
317
  this.nameInput.value = name;
239
318
  }
240
319
 
241
- // Get feature history JSON (now includes PMI views) and embed into a 3MF archive as Metadata/featureHistory.json
242
- const jsonString = await this.viewer.partHistory.toJSON();
243
- let additionalFiles = undefined;
244
- let modelMetadata = undefined;
245
- if (jsonString) {
246
- additionalFiles = { 'Metadata/featureHistory.json': jsonString };
247
- modelMetadata = { featureHistoryPath: '/Metadata/featureHistory.json' };
248
- }
249
- // Embed PMI view images under /views
320
+ try { console.log('[FileManagerWidget] saveCurrent: begin', { name }); } catch { }
321
+ this._setSaveBusy(true);
322
+ this._startSaveProgress(`Saving "${name}"...`);
250
323
  try {
251
- const viewFiles = await this.viewer?.pmiViewsWidget?.captureViewImagesForPackage?.();
252
- if (viewFiles && typeof viewFiles === 'object') {
253
- additionalFiles = { ...(additionalFiles || {}), ...viewFiles };
324
+ this._logSaveProgress('Preparing feature history...');
325
+ // Get feature history JSON (now includes PMI views) and embed into a 3MF archive as Metadata/featureHistory.json
326
+ const jsonString = await this.viewer.partHistory.toJSON();
327
+ try { console.log('[FileManagerWidget] saveCurrent: feature history', { bytes: jsonString ? jsonString.length : 0 }); } catch { }
328
+ let additionalFiles = {};
329
+ let modelMetadata = undefined;
330
+ if (jsonString) {
331
+ additionalFiles['Metadata/featureHistory.json'] = jsonString;
332
+ modelMetadata = { featureHistoryPath: '/Metadata/featureHistory.json' };
254
333
  }
255
- } catch (err) {
256
- console.error('Failed to embed PMI view images:', err);
257
- }
258
- // Capture a 60x60 thumbnail of the current view
259
- let thumbnail = null;
260
- try {
261
- thumbnail = await this._captureThumbnail(60);
262
- } catch { /* ignore thumbnail failures */ }
263
-
264
- // Collect solids for full 3MF export (so slicers can open it).
265
- const solids = this._collectSolidsForExport();
266
- const solidsForExport = [];
267
- const skipped = [];
268
- solids.forEach((s, idx) => {
334
+ // Embed PMI view images under /views
269
335
  try {
270
- const mesh = s?.getMesh?.();
271
- if (mesh && mesh.vertProperties && mesh.triVerts) {
272
- solidsForExport.push(s);
273
- } else {
274
- skipped.push(s?.name || `solid_${idx}`);
336
+ this._logSaveProgress('Capturing PMI view images...');
337
+ const viewFiles = await this.viewer?.pmiViewsWidget?.captureViewImagesForPackage?.();
338
+ if (viewFiles && typeof viewFiles === 'object') {
339
+ additionalFiles = { ...(additionalFiles || {}), ...viewFiles };
275
340
  }
276
- } catch {
277
- skipped.push(s?.name || `solid_${idx}`);
341
+ } catch (err) {
342
+ console.error('Failed to embed PMI view images:', err);
278
343
  }
279
- });
344
+ // Capture a 60x60 thumbnail of the current view
345
+ let thumbnail = null;
346
+ try {
347
+ this._logSaveProgress('Capturing thumbnail...');
348
+ thumbnail = await this._captureThumbnail(60);
349
+ } catch { /* ignore thumbnail failures */ }
280
350
 
281
- let threeMfBytes;
282
- try {
283
- const metadataManager = this.viewer?.partHistory?.metadataManager || null;
284
- threeMfBytes = await generate3MF(solidsForExport, {
285
- unit: 'millimeter',
286
- precision: 6,
287
- scale: 1,
288
- additionalFiles,
289
- modelMetadata,
290
- thumbnail,
291
- metadataManager,
292
- });
293
- } catch (e) {
294
- // Fallback: history only 3MF
295
- const metadataManager = this.viewer?.partHistory?.metadataManager || null;
296
- threeMfBytes = await generate3MF([], {
297
- unit: 'millimeter',
298
- precision: 6,
299
- scale: 1,
300
- additionalFiles,
301
- modelMetadata,
302
- thumbnail,
303
- metadataManager,
351
+ // Collect solids for full 3MF export (so slicers can open it).
352
+ this._logSaveProgress('Collecting solids...');
353
+ const solids = this._collectSolidsForExport();
354
+ try { console.log('[FileManagerWidget] saveCurrent: collected solids', { count: solids.length, names: solids.map(s => s?.name).filter(Boolean) }); } catch { }
355
+ const solidsForExport = [];
356
+ const skipped = [];
357
+ solids.forEach((s, idx) => {
358
+ try {
359
+ const mesh = s?.getMesh?.();
360
+ if (mesh && mesh.vertProperties && mesh.triVerts) {
361
+ solidsForExport.push(s);
362
+ } else {
363
+ skipped.push(s?.name || `solid_${idx}`);
364
+ }
365
+ } catch {
366
+ skipped.push(s?.name || `solid_${idx}`);
367
+ }
304
368
  });
305
- console.warn('[FileManagerWidget] 3MF export failed for solids, saved history-only 3MF.', e);
306
- }
307
- const threeMfB64 = uint8ArrayToBase64(threeMfBytes);
308
- const now = new Date().toISOString();
309
-
310
- // Store only the 3MF (with embedded thumbnail) and timestamp
311
- const record = { savedAt: now, data3mf: threeMfB64 };
312
- if (thumbnail) record.thumbnail = thumbnail;
313
- await this._setModel(name, record);
314
- // Update in-memory thumbnail cache so UI reflects the new preview immediately
315
- try { if (thumbnail) this._thumbCache.set(name, thumbnail); } catch { }
316
- this.currentName = name;
317
- this._saveLastName(name);
318
- await this.refreshList();
319
- if (skipped.length) {
320
- try { console.warn('[FileManagerWidget] Skipped non-manifold solids:', skipped); } catch {}
369
+ try { console.log('[FileManagerWidget] saveCurrent: solids for export', { count: solidsForExport.length, skipped }); } catch { }
370
+
371
+ // Attach BREP-specific metadata for mesh-based restores (face names, colors, centerlines).
372
+ try {
373
+ this._logSaveProgress('Packaging BREP metadata...');
374
+ const extras = this._buildBrepExtras(solidsForExport);
375
+ try { console.log('[FileManagerWidget] saveCurrent: brepExtras', { hasExtras: !!extras, solidCount: extras?.solids ? Object.keys(extras.solids).length : 0 }); } catch { }
376
+ if (extras) {
377
+ additionalFiles = additionalFiles || {};
378
+ additionalFiles['Metadata/brepExtras.json'] = JSON.stringify(extras);
379
+ }
380
+ } catch (err) {
381
+ console.warn('[FileManagerWidget] Failed to embed BREP extras:', err);
382
+ }
383
+
384
+ let threeMfBytes;
385
+ try {
386
+ this._logSaveProgress('Exporting 3MF...');
387
+ const metadataManager = this.viewer?.partHistory?.metadataManager || null;
388
+ const defaultFaceColor = (() => {
389
+ try {
390
+ const color = CADmaterials?.FACE?.BASE?.color;
391
+ if (color && typeof color.getHexString === 'function') {
392
+ return `#${color.getHexString()}`;
393
+ }
394
+ if (typeof color === 'string') return color;
395
+ } catch { }
396
+ return null;
397
+ })();
398
+ threeMfBytes = await generate3MF(solidsForExport, {
399
+ unit: 'millimeter',
400
+ precision: 6,
401
+ scale: 1,
402
+ additionalFiles,
403
+ modelMetadata,
404
+ thumbnail,
405
+ metadataManager,
406
+ defaultFaceColor,
407
+ });
408
+ try { console.log('[FileManagerWidget] saveCurrent: 3MF exported', { bytes: threeMfBytes?.length || 0 }); } catch { }
409
+ try {
410
+ const zip = await JSZip.loadAsync(threeMfBytes);
411
+ const files = {};
412
+ Object.keys(zip.files || {}).forEach(p => { files[p.toLowerCase()] = p; });
413
+ const modelPath = files['3d/3dmodel.model'] || files['/3d/3dmodel.model'];
414
+ const modelFile = modelPath ? zip.file(modelPath) : null;
415
+ if (modelFile) {
416
+ const xml = await modelFile.async('string');
417
+ const triCount = (xml.match(/<triangle\b/gi) || []).length;
418
+ const objCount = (xml.match(/<object\b/gi) || []).length;
419
+ console.log('[FileManagerWidget] saveCurrent: 3MF model stats', { objects: objCount, triangles: triCount });
420
+ } else {
421
+ console.warn('[FileManagerWidget] saveCurrent: 3MF model file not found in zip');
422
+ }
423
+ } catch (err) {
424
+ try { console.warn('[FileManagerWidget] saveCurrent: 3MF model stats failed', err?.message || err); } catch { }
425
+ }
426
+ } catch (e) {
427
+ // Fallback: history only 3MF
428
+ const metadataManager = this.viewer?.partHistory?.metadataManager || null;
429
+ const defaultFaceColor = (() => {
430
+ try {
431
+ const color = CADmaterials?.FACE?.BASE?.color;
432
+ if (color && typeof color.getHexString === 'function') {
433
+ return `#${color.getHexString()}`;
434
+ }
435
+ if (typeof color === 'string') return color;
436
+ } catch { }
437
+ return null;
438
+ })();
439
+ threeMfBytes = await generate3MF([], {
440
+ unit: 'millimeter',
441
+ precision: 6,
442
+ scale: 1,
443
+ additionalFiles,
444
+ modelMetadata,
445
+ thumbnail,
446
+ metadataManager,
447
+ defaultFaceColor,
448
+ });
449
+ console.warn('[FileManagerWidget] 3MF export failed for solids, saved history-only 3MF.', e);
450
+ try { console.log('[FileManagerWidget] saveCurrent: 3MF exported (history only)', { bytes: threeMfBytes?.length || 0 }); } catch { }
451
+ try {
452
+ const zip = await JSZip.loadAsync(threeMfBytes);
453
+ const files = {};
454
+ Object.keys(zip.files || {}).forEach(p => { files[p.toLowerCase()] = p; });
455
+ const modelPath = files['3d/3dmodel.model'] || files['/3d/3dmodel.model'];
456
+ const modelFile = modelPath ? zip.file(modelPath) : null;
457
+ if (modelFile) {
458
+ const xml = await modelFile.async('string');
459
+ const triCount = (xml.match(/<triangle\b/gi) || []).length;
460
+ const objCount = (xml.match(/<object\b/gi) || []).length;
461
+ console.log('[FileManagerWidget] saveCurrent: 3MF model stats (history only)', { objects: objCount, triangles: triCount });
462
+ } else {
463
+ console.warn('[FileManagerWidget] saveCurrent: 3MF model file not found in zip (history only)');
464
+ }
465
+ } catch (err) {
466
+ try { console.warn('[FileManagerWidget] saveCurrent: 3MF model stats failed (history only)', err?.message || err); } catch { }
467
+ }
468
+ }
469
+ const threeMfB64 = uint8ArrayToBase64(threeMfBytes);
470
+ const now = new Date().toISOString();
471
+
472
+ // Store only the 3MF (with embedded thumbnail) and timestamp
473
+ const record = { savedAt: now, data3mf: threeMfB64 };
474
+ if (thumbnail) record.thumbnail = thumbnail;
475
+ if (LS?.isGithub?.()) {
476
+ this._logSaveProgress('Saving to GitHub...');
477
+ try { console.log('[FileManagerWidget] saveCurrent: saving to GitHub', { name }); } catch { }
478
+ const res = await this._retryGithubOperation(
479
+ `Save "${name}" to GitHub`,
480
+ () => this._setModel(name, record),
481
+ (msg) => this._logSaveProgress(msg)
482
+ );
483
+ if (!res.ok) {
484
+ this._logSaveProgress('Save canceled.');
485
+ return;
486
+ }
487
+ try {
488
+ this._pendingGithubMeta.set(name, {
489
+ savedAt: record.savedAt || null,
490
+ thumbnail: record.thumbnail || null,
491
+ });
492
+ } catch { /* ignore */ }
493
+ } else {
494
+ this._logSaveProgress('Saving to local storage...');
495
+ try { console.log('[FileManagerWidget] saveCurrent: saving locally', { name }); } catch { }
496
+ await this._setModel(name, record);
497
+ }
498
+ // Update in-memory thumbnail cache so UI reflects the new preview immediately
499
+ try { if (thumbnail) this._thumbCache.set(name, thumbnail); } catch { }
500
+ this.currentName = name;
501
+ this._saveLastName(name);
502
+ this._logSaveProgress('Refreshing list...');
503
+ await this.refreshList();
504
+ this._logSaveProgress('Save complete.');
505
+ try { console.log('[FileManagerWidget] saveCurrent: complete', { name }); } catch { }
506
+ if (skipped.length) {
507
+ try { console.warn('[FileManagerWidget] Skipped non-manifold solids:', skipped); } catch {}
508
+ }
509
+ } catch (err) {
510
+ const msg = (err && err.message) ? err.message : String(err || 'Unknown error');
511
+ this._logSaveProgress(`Save failed: ${msg}`);
512
+ try { console.warn('[FileManagerWidget] saveCurrent: failed', { name, error: msg }); } catch { }
513
+ throw err;
514
+ } finally {
515
+ this._endSaveProgress();
516
+ this._setSaveBusy(false);
321
517
  }
322
518
  }
323
519
 
@@ -333,10 +529,214 @@ export class FileManagerWidget {
333
529
  return selected.length ? selected : solids;
334
530
  }
335
531
 
532
+ _buildBrepExtras(solids) {
533
+ if (!Array.isArray(solids) || solids.length === 0) return null;
534
+
535
+ const cleanMeta = (value) => {
536
+ if (value == null) return null;
537
+ try {
538
+ return JSON.parse(JSON.stringify(value, (key, v) => {
539
+ if (typeof v === 'function') return undefined;
540
+ if (v && v.isColor && typeof v.getHexString === 'function') {
541
+ try { return `#${v.getHexString()}`; } catch { return v; }
542
+ }
543
+ return v;
544
+ }));
545
+ } catch {
546
+ return null;
547
+ }
548
+ };
549
+
550
+ const mapToObject = (map) => {
551
+ if (!(map instanceof Map) || map.size === 0) return null;
552
+ const out = {};
553
+ for (const [key, val] of map.entries()) {
554
+ if (key == null) continue;
555
+ const cleaned = cleanMeta(val);
556
+ if (cleaned != null) out[String(key)] = cleaned;
557
+ }
558
+ return Object.keys(out).length ? out : null;
559
+ };
560
+
561
+ const encodeTriIds = (triIds) => {
562
+ if (!triIds || triIds.length === 0) return '';
563
+ const u32 = triIds instanceof Uint32Array ? triIds : Uint32Array.from(triIds);
564
+ const u8 = new Uint8Array(u32.buffer, u32.byteOffset, u32.byteLength);
565
+ return uint8ArrayToBase64(u8);
566
+ };
567
+
568
+ const solidsOut = {};
569
+ const metadataManager = this.viewer?.partHistory?.metadataManager;
570
+ for (const solid of solids) {
571
+ if (!solid || solid.type !== 'SOLID') continue;
572
+ const name = String(solid.name || '').trim();
573
+ if (!name) continue;
574
+
575
+ const authorTriCount = Array.isArray(solid._triVerts) ? (solid._triVerts.length / 3) : 0;
576
+ const authorTriIdCount = Array.isArray(solid._triIDs) ? solid._triIDs.length : 0;
577
+ let triIds = solid._triIDs || [];
578
+ let triCount = (Array.isArray(triIds) || triIds instanceof Uint32Array) ? triIds.length : 0;
579
+ let triIdsOrdered = triIds;
580
+ let mesh = null;
581
+ let triMat = null;
582
+ let meshTriCount = 0;
583
+ let meshFaceIdCount = 0;
584
+ try {
585
+ if (typeof solid.getMesh === 'function') {
586
+ mesh = solid.getMesh();
587
+ if (mesh && mesh.faceID && mesh.faceID.length) {
588
+ triIds = Array.from(mesh.faceID);
589
+ triCount = triIds.length;
590
+ }
591
+ meshTriCount = (mesh?.triVerts && mesh.triVerts.length) ? (mesh.triVerts.length / 3) : 0;
592
+ meshFaceIdCount = (mesh?.faceID && mesh.faceID.length) ? mesh.faceID.length : 0;
593
+ try {
594
+ triMat = computeTriangleMaterialIndices(solid, mesh, {
595
+ metadataManager,
596
+ includeFaceTags: true,
597
+ useMetadataColors: true,
598
+ });
599
+ } catch { /* ignore material mapping */ }
600
+ }
601
+ } catch { /* ignore mesh failures */ }
602
+ finally { try { mesh?.delete?.(); } catch { } }
603
+
604
+ if (triMat && Array.isArray(triMat) && triMat.length === triCount && triCount > 0) {
605
+ const buckets = new Map();
606
+ let defaultBucket = null;
607
+ for (let t = 0; t < triCount; t++) {
608
+ const fid = triIds[t];
609
+ const midx = triMat[t];
610
+ if (midx == null || !Number.isFinite(midx)) {
611
+ if (!defaultBucket) defaultBucket = [];
612
+ defaultBucket.push(fid);
613
+ } else {
614
+ const key = Number(midx);
615
+ let arr = buckets.get(key);
616
+ if (!arr) { arr = []; buckets.set(key, arr); }
617
+ arr.push(fid);
618
+ }
619
+ }
620
+ if (buckets.size || (defaultBucket && defaultBucket.length)) {
621
+ const ordered = [];
622
+ const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
623
+ for (const k of keys) {
624
+ const arr = buckets.get(k);
625
+ if (arr && arr.length) ordered.push(...arr);
626
+ }
627
+ if (defaultBucket && defaultBucket.length) ordered.push(...defaultBucket);
628
+ triIdsOrdered = ordered;
629
+ triCount = triIdsOrdered.length;
630
+ }
631
+ } else {
632
+ triIdsOrdered = triIds;
633
+ triCount = (Array.isArray(triIdsOrdered) || triIdsOrdered instanceof Uint32Array) ? triIdsOrdered.length : 0;
634
+ }
635
+ try {
636
+ console.log('[FileManagerWidget] brepExtras: counts', {
637
+ name,
638
+ authorTriCount,
639
+ authorTriIdCount,
640
+ meshTriCount,
641
+ meshFaceIdCount,
642
+ triIdsCount: (Array.isArray(triIds) || triIds instanceof Uint32Array) ? triIds.length : 0,
643
+ triIdsOrderedCount: (Array.isArray(triIdsOrdered) || triIdsOrdered instanceof Uint32Array) ? triIdsOrdered.length : 0,
644
+ triMatCount: Array.isArray(triMat) ? triMat.length : 0,
645
+ });
646
+ } catch { }
647
+ try {
648
+ console.log('[FileManagerWidget] brepExtras: solid', {
649
+ name,
650
+ triCount,
651
+ faceMapCount: idToFaceName ? Object.keys(idToFaceName).length : 0,
652
+ faceMetaCount: faceMetadata ? Object.keys(faceMetadata).length : 0,
653
+ edgeMetaCount: edgeMetadata ? Object.keys(edgeMetadata).length : 0,
654
+ triFaceOrder: 'material',
655
+ });
656
+ } catch { }
657
+ let idToFaceName = (solid._idToFaceName instanceof Map)
658
+ ? Object.fromEntries(Array.from(solid._idToFaceName.entries()).map(([k, v]) => [String(k), String(v)]))
659
+ : null;
660
+ if (!idToFaceName && solid._faceNameToID instanceof Map) {
661
+ const inverted = {};
662
+ for (const [faceName, faceId] of solid._faceNameToID.entries()) {
663
+ if (faceId == null || faceName == null) continue;
664
+ inverted[String(faceId)] = String(faceName);
665
+ }
666
+ if (Object.keys(inverted).length) idToFaceName = inverted;
667
+ }
668
+
669
+ let faceMetadata = mapToObject(solid._faceMetadata);
670
+ const edgeMetadata = mapToObject(solid._edgeMetadata);
671
+ const solidUserMeta = cleanMeta(solid?.userData?.metadata || null);
672
+ const solidManagerMeta = (metadataManager && typeof metadataManager.getMetadata === 'function')
673
+ ? cleanMeta(metadataManager.getMetadata(name))
674
+ : null;
675
+ const solidMetadata = solidManagerMeta
676
+ ? { ...(solidManagerMeta || {}), ...(solidUserMeta || {}) }
677
+ : solidUserMeta;
678
+
679
+ if (metadataManager && typeof metadataManager.getMetadata === 'function' && idToFaceName) {
680
+ const mergedFaceMeta = faceMetadata || {};
681
+ for (const faceName of Object.values(idToFaceName)) {
682
+ if (!faceName) continue;
683
+ const meta = cleanMeta(metadataManager.getMetadata(faceName));
684
+ if (meta && typeof meta === 'object' && Object.keys(meta).length) {
685
+ mergedFaceMeta[faceName] = { ...(meta || {}), ...(mergedFaceMeta[faceName] || {}) };
686
+ }
687
+ }
688
+ faceMetadata = Object.keys(mergedFaceMeta).length ? mergedFaceMeta : faceMetadata;
689
+ }
690
+
691
+ let auxEdges = null;
692
+ if (Array.isArray(solid._auxEdges) && solid._auxEdges.length) {
693
+ auxEdges = solid._auxEdges.map((e) => {
694
+ const pts = Array.isArray(e?.points)
695
+ ? e.points
696
+ .map((p) => (Array.isArray(p) && p.length === 3 ? [p[0], p[1], p[2]] : null))
697
+ .filter(Boolean)
698
+ : [];
699
+ return {
700
+ name: e?.name || '',
701
+ points: pts,
702
+ closedLoop: !!e?.closedLoop,
703
+ polylineWorld: !!e?.polylineWorld,
704
+ materialKey: e?.materialKey || undefined,
705
+ centerline: !!e?.centerline,
706
+ faceA: typeof e?.faceA === 'string' ? e.faceA : undefined,
707
+ faceB: typeof e?.faceB === 'string' ? e.faceB : undefined,
708
+ };
709
+ }).filter((e) => Array.isArray(e.points) && e.points.length >= 2);
710
+ }
711
+
712
+ if (faceMetadata && Object.keys(faceMetadata).length === 0) faceMetadata = null;
713
+ solidsOut[name] = {
714
+ triCount,
715
+ triFaceIdsB64: encodeTriIds(triIdsOrdered),
716
+ triFaceOrder: 'material',
717
+ idToFaceName,
718
+ faceMetadata,
719
+ edgeMetadata,
720
+ auxEdges,
721
+ solidMetadata,
722
+ };
723
+ }
724
+
725
+ if (!Object.keys(solidsOut).length) return null;
726
+ return { version: 1, solids: solidsOut };
727
+ }
728
+
336
729
  async loadModel(name) {
337
730
  if (!this.viewer || !this.viewer.partHistory) return;
338
731
  const seq = ++this._loadSeq; // only the last call should win
339
- const rec = await this._getModel(name);
732
+ let rec = null;
733
+ if (LS?.isGithub?.()) {
734
+ const res = await this._retryGithubOperation(`Load "${name}" from GitHub`, () => this._getModel(name, { throwOnError: true }));
735
+ if (!res.ok) return;
736
+ rec = res.value;
737
+ } else {
738
+ rec = await this._getModel(name);
739
+ }
340
740
  if (!rec) return alert('Model not found.');
341
741
  await this.viewer.partHistory.reset();
342
742
  // Prefer new 3MF-based storage
@@ -461,6 +861,26 @@ export class FileManagerWidget {
461
861
 
462
862
  async refreshList() {
463
863
  const items = await this._listModels();
864
+ if (LS?.isGithub?.() && this._pendingGithubMeta && this._pendingGithubMeta.size) {
865
+ for (const it of items) {
866
+ const pending = this._pendingGithubMeta.get(it.name);
867
+ if (!pending) continue;
868
+ const itemTime = it.savedAt ? Date.parse(it.savedAt) : NaN;
869
+ const pendingTime = pending.savedAt ? Date.parse(pending.savedAt) : NaN;
870
+ if (!Number.isFinite(itemTime) || (Number.isFinite(pendingTime) && pendingTime > itemTime)) {
871
+ if (pending.savedAt) it.savedAt = pending.savedAt;
872
+ if (it.record && pending.savedAt) it.record.savedAt = pending.savedAt;
873
+ if (pending.thumbnail) {
874
+ it.thumbnail = pending.thumbnail;
875
+ if (it.record) it.record.thumbnail = pending.thumbnail;
876
+ try { this._thumbCache.set(it.name, pending.thumbnail); } catch { }
877
+ }
878
+ } else {
879
+ // Remote metadata caught up; drop the pending override.
880
+ this._pendingGithubMeta.delete(it.name);
881
+ }
882
+ }
883
+ }
464
884
  while (this.listEl.firstChild) this.listEl.removeChild(this.listEl.firstChild);
465
885
 
466
886
  if (!items.length) {