pcb-scene3d-viewer 1.1.21 → 1.1.22

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.
@@ -1,4 +1,9 @@
1
1
  import { zipSync } from 'fflate'
2
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
+ import { PcbAssemblyModelMeshLoader } from './PcbAssemblyModelMeshLoader.mjs'
4
+ import { PcbAssemblyStepWriter } from './PcbAssemblyStepWriter.mjs'
5
+
6
+ const STATIC_BODY_COLOR = [0.5, 0.5, 0.5]
2
7
 
3
8
  /**
4
9
  * Builds browser-downloadable ZIP archives from resolved PCB component model
@@ -7,16 +12,19 @@ import { zipSync } from 'fflate'
7
12
  export class PcbModelArchiveExporter {
8
13
  /**
9
14
  * Builds one ZIP archive from the resolved scene description.
10
- * @param {{ archiveBaseName?: string, sceneDescription?: { components?: any[], externalPlacements?: any[] } }} [options]
11
- * @returns {Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: { archivePath: string, pattern: string, designator: string, format: string, modelName: string }[], skippedEntries: { designator: string, reason: string }[] }>}
15
+ * @param {{ archiveBaseName?: string, sceneDescription?: { components?: any[], externalPlacements?: any[], staticBodyPlacements?: any[] }, modelMeshLoader?: ((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader, includeRawModels?: boolean, includeStitchedComponents?: boolean, stitchedDesignators?: string[] }} [options]
16
+ * @returns {Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: { archivePath: string, pattern: string, designator: string, format: string, modelName: string, kind?: string }[], skippedEntries: { designator: string, reason: string }[] }>}
12
17
  */
13
18
  static async buildArchive(options = {}) {
14
19
  const archiveName = PcbModelArchiveExporter.#resolveArchiveName(
15
20
  options.archiveBaseName
16
21
  )
17
- const resolvedEntries = PcbModelArchiveExporter.#collectResolvedEntries(
18
- options.sceneDescription
19
- )
22
+ const resolvedEntries =
23
+ options.includeRawModels === false
24
+ ? []
25
+ : PcbModelArchiveExporter.#collectResolvedEntries(
26
+ options.sceneDescription
27
+ )
20
28
  const exportedEntries = []
21
29
  const skippedEntries = []
22
30
  const archiveInput = {}
@@ -47,6 +55,45 @@ export class PcbModelArchiveExporter {
47
55
  }
48
56
  }
49
57
 
58
+ const stitchedEntries =
59
+ options.includeStitchedComponents === false
60
+ ? { entries: [], skippedEntries: [] }
61
+ : await PcbModelArchiveExporter.#buildStitchedEntries(
62
+ options.sceneDescription,
63
+ options.modelMeshLoader,
64
+ options.stitchedDesignators
65
+ )
66
+ skippedEntries.push(...stitchedEntries.skippedEntries)
67
+ for (const entry of stitchedEntries.entries) {
68
+ try {
69
+ const archivePath =
70
+ PcbModelArchiveExporter.#resolveStitchedArchivePath(
71
+ entry,
72
+ exportedEntries
73
+ )
74
+ archiveInput[archivePath] = new TextEncoder().encode(
75
+ entry.payloadText
76
+ )
77
+ exportedEntries.push({
78
+ archivePath,
79
+ pattern: entry.pattern,
80
+ designator: entry.designator,
81
+ format: 'step',
82
+ modelName: entry.modelName,
83
+ kind: 'stitched-component'
84
+ })
85
+ } catch (error) {
86
+ skippedEntries.push({
87
+ designator: entry.designator,
88
+ reason: String(
89
+ error?.message ||
90
+ error ||
91
+ 'Stitched component export failed.'
92
+ )
93
+ })
94
+ }
95
+ }
96
+
50
97
  return {
51
98
  archiveName,
52
99
  archiveBytes: zipSync(archiveInput),
@@ -145,6 +192,387 @@ export class PcbModelArchiveExporter {
145
192
  return dedupedEntries
146
193
  }
147
194
 
195
+ /**
196
+ * Builds generated STEP entries for authored multi-body components.
197
+ * @param {{ components?: object[], externalPlacements?: object[], staticBodyPlacements?: object[] } | undefined} sceneDescription Scene data.
198
+ * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader | undefined} modelMeshLoader Mesh loader override.
199
+ * @param {string[] | undefined} stitchedDesignators Optional designator filter.
200
+ * @returns {Promise<{ entries: { pattern: string, designator: string, modelName: string, payloadText: string }[], skippedEntries: { designator: string, reason: string }[] }>}
201
+ */
202
+ static async #buildStitchedEntries(
203
+ sceneDescription,
204
+ modelMeshLoader,
205
+ stitchedDesignators
206
+ ) {
207
+ const candidates = PcbModelArchiveExporter.#collectStitchedCandidates(
208
+ sceneDescription,
209
+ stitchedDesignators
210
+ )
211
+ if (!candidates.length) {
212
+ return { entries: [], skippedEntries: [] }
213
+ }
214
+
215
+ const loader =
216
+ PcbModelArchiveExporter.#resolveModelMeshLoader(modelMeshLoader)
217
+ const ownsLoader =
218
+ !(
219
+ typeof modelMeshLoader === 'function' ||
220
+ modelMeshLoader instanceof PcbAssemblyModelMeshLoader
221
+ ) && loader instanceof PcbAssemblyModelMeshLoader
222
+ try {
223
+ const entries = []
224
+ const skippedEntries = []
225
+ for (const candidate of candidates) {
226
+ try {
227
+ const meshes =
228
+ await PcbModelArchiveExporter.#buildStitchedMeshes(
229
+ candidate,
230
+ loader
231
+ )
232
+ if (!meshes.length) {
233
+ continue
234
+ }
235
+
236
+ const modelName =
237
+ candidate.designator + ' stitched component'
238
+ entries.push({
239
+ pattern: candidate.pattern,
240
+ designator: candidate.designator,
241
+ modelName,
242
+ payloadText: PcbAssemblyStepWriter.write({
243
+ name: modelName,
244
+ meshes
245
+ })
246
+ })
247
+ } catch (error) {
248
+ skippedEntries.push({
249
+ designator: candidate.designator,
250
+ reason: String(
251
+ error?.message ||
252
+ error ||
253
+ 'Stitched component export failed.'
254
+ )
255
+ })
256
+ }
257
+ }
258
+
259
+ return { entries, skippedEntries }
260
+ } finally {
261
+ if (ownsLoader) {
262
+ loader.dispose?.()
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Collects component stack candidates keyed by designator.
269
+ * @param {{ components?: object[], externalPlacements?: object[], staticBodyPlacements?: object[] } | undefined} sceneDescription Scene data.
270
+ * @param {string[] | undefined} stitchedDesignators Optional designator filter.
271
+ * @returns {{ designator: string, pattern: string, externalPlacements: object[], staticBodyPlacements: object[] }[]}
272
+ */
273
+ static #collectStitchedCandidates(sceneDescription, stitchedDesignators) {
274
+ const componentByDesignator =
275
+ PcbModelArchiveExporter.#componentByDesignator(sceneDescription)
276
+ const designatorSet =
277
+ PcbModelArchiveExporter.#normalizedDesignatorSet(
278
+ stitchedDesignators
279
+ )
280
+ const groups = new Map()
281
+
282
+ PcbModelArchiveExporter.#array(
283
+ sceneDescription?.staticBodyPlacements
284
+ ).forEach((placement) => {
285
+ const designator = String(placement?.designator || '').trim()
286
+ if (!designator) {
287
+ return
288
+ }
289
+
290
+ PcbModelArchiveExporter.#stitchedGroup(
291
+ groups,
292
+ designator
293
+ ).staticBodyPlacements.push(placement)
294
+ })
295
+
296
+ PcbModelArchiveExporter.#array(sceneDescription?.externalPlacements)
297
+ .filter((placement) => placement?.externalModel)
298
+ .forEach((placement) => {
299
+ const designator = String(placement?.designator || '').trim()
300
+ if (!designator) {
301
+ return
302
+ }
303
+
304
+ PcbModelArchiveExporter.#stitchedGroup(
305
+ groups,
306
+ designator
307
+ ).externalPlacements.push(placement)
308
+ })
309
+
310
+ return [...groups.values()]
311
+ .filter(
312
+ (group) =>
313
+ !designatorSet ||
314
+ designatorSet.has(
315
+ PcbModelArchiveExporter.#normalizeDesignator(
316
+ group.designator
317
+ )
318
+ )
319
+ )
320
+ .filter((group) =>
321
+ PcbModelArchiveExporter.#isStitchedCandidate(group)
322
+ )
323
+ .map((group) => ({
324
+ ...group,
325
+ pattern: PcbModelArchiveExporter.#resolvePattern(
326
+ componentByDesignator.get(group.designator) || null,
327
+ group.externalPlacements[0] || null
328
+ )
329
+ }))
330
+ .sort((left, right) =>
331
+ left.designator.localeCompare(right.designator)
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Builds a normalized designator filter set.
337
+ * @param {unknown} designators Candidate designator list.
338
+ * @returns {Set<string> | null}
339
+ */
340
+ static #normalizedDesignatorSet(designators) {
341
+ if (!Array.isArray(designators) || !designators.length) {
342
+ return null
343
+ }
344
+
345
+ const normalized = designators
346
+ .map((designator) =>
347
+ PcbModelArchiveExporter.#normalizeDesignator(designator)
348
+ )
349
+ .filter(Boolean)
350
+
351
+ return normalized.length ? new Set(normalized) : null
352
+ }
353
+
354
+ /**
355
+ * Normalizes a component designator for matching.
356
+ * @param {unknown} designator Candidate designator.
357
+ * @returns {string}
358
+ */
359
+ static #normalizeDesignator(designator) {
360
+ return String(designator || '')
361
+ .trim()
362
+ .toUpperCase()
363
+ }
364
+
365
+ /**
366
+ * Returns true when one designator represents a multi-body component.
367
+ * @param {{ externalPlacements: object[], staticBodyPlacements: object[] }} group Candidate group.
368
+ * @returns {boolean}
369
+ */
370
+ static #isStitchedCandidate(group) {
371
+ const hasVariantMetadata = [
372
+ ...group.externalPlacements,
373
+ ...group.staticBodyPlacements
374
+ ].some((placement) =>
375
+ String(placement?.coLocatedVariantGroupKey || '').trim()
376
+ )
377
+ return (
378
+ group.externalPlacements.length > 0 &&
379
+ (group.externalPlacements.length > 1 ||
380
+ group.staticBodyPlacements.length > 0 ||
381
+ hasVariantMetadata)
382
+ )
383
+ }
384
+
385
+ /**
386
+ * Builds transformed, component-local meshes for one stitched component.
387
+ * @param {{ designator: string, externalPlacements: object[], staticBodyPlacements: object[] }} candidate Stitched component candidate.
388
+ * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader} loader Mesh loader.
389
+ * @returns {Promise<object[]>}
390
+ */
391
+ static async #buildStitchedMeshes(candidate, loader) {
392
+ const anchor = PcbModelArchiveExporter.#stitchedAnchor(candidate)
393
+ const staticMeshes = candidate.staticBodyPlacements
394
+ .map((placement) =>
395
+ PcbModelArchiveExporter.#staticBodyMesh(
396
+ candidate.designator,
397
+ placement
398
+ )
399
+ )
400
+ .filter(Boolean)
401
+ const externalMeshes = []
402
+
403
+ for (const placement of candidate.externalPlacements) {
404
+ const loaded = await PcbModelArchiveExporter.#loadPlacementMeshes(
405
+ loader,
406
+ placement
407
+ )
408
+ loaded.filter(Boolean).forEach((mesh, index) => {
409
+ externalMeshes.push(
410
+ PcbAssemblyMeshUtils.transformMesh(
411
+ {
412
+ ...mesh,
413
+ name:
414
+ mesh.name ||
415
+ candidate.designator + '-model-' + (index + 1)
416
+ },
417
+ placement
418
+ )
419
+ )
420
+ })
421
+ }
422
+
423
+ return PcbModelArchiveExporter.#translateMeshes(
424
+ [...staticMeshes, ...externalMeshes],
425
+ {
426
+ x: -anchor.x,
427
+ y: -anchor.y,
428
+ z: -anchor.z
429
+ }
430
+ )
431
+ }
432
+
433
+ /**
434
+ * Builds one mesh for an authored static carrier body.
435
+ * @param {string} designator Component designator.
436
+ * @param {object} placement Static body placement.
437
+ * @returns {object | null}
438
+ */
439
+ static #staticBodyMesh(designator, placement) {
440
+ const geometry = placement?.geometry || {}
441
+ const kind = String(geometry?.kind || '').toLowerCase()
442
+ const height = Number(geometry?.heightMil || 0)
443
+ if (!(height > 0)) {
444
+ return null
445
+ }
446
+
447
+ let mesh = null
448
+ if (kind === 'extruded-polygon') {
449
+ const points = PcbModelArchiveExporter.#array(
450
+ geometry.verticesMil
451
+ ).map((point) => [Number(point?.x || 0), Number(point?.y || 0)])
452
+ mesh = PcbAssemblyMeshUtils.prism(
453
+ 'static-' + PcbAssemblyMeshUtils.safeName(designator),
454
+ points,
455
+ 0,
456
+ height,
457
+ PcbModelArchiveExporter.#bodyColor(placement)
458
+ )
459
+ } else if (kind === 'cylinder') {
460
+ mesh = PcbAssemblyMeshUtils.cylinder(
461
+ 'static-' + PcbAssemblyMeshUtils.safeName(designator),
462
+ {
463
+ radius: Number(geometry?.radiusMil || 0),
464
+ height,
465
+ color: PcbModelArchiveExporter.#bodyColor(placement)
466
+ }
467
+ )
468
+ }
469
+
470
+ return mesh ? PcbAssemblyMeshUtils.transformMesh(mesh, placement) : null
471
+ }
472
+
473
+ /**
474
+ * Resolves a mesh loader for generated stitched components.
475
+ * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader | undefined} modelMeshLoader Loader option.
476
+ * @returns {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader}
477
+ */
478
+ static #resolveModelMeshLoader(modelMeshLoader) {
479
+ if (
480
+ typeof modelMeshLoader === 'function' ||
481
+ modelMeshLoader instanceof PcbAssemblyModelMeshLoader
482
+ ) {
483
+ return modelMeshLoader
484
+ }
485
+
486
+ return new PcbAssemblyModelMeshLoader()
487
+ }
488
+
489
+ /**
490
+ * Loads meshes for one external placement.
491
+ * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader} loader Mesh loader.
492
+ * @param {object} placement External placement.
493
+ * @returns {Promise<object[]>}
494
+ */
495
+ static async #loadPlacementMeshes(loader, placement) {
496
+ const loaded =
497
+ loader instanceof PcbAssemblyModelMeshLoader
498
+ ? await loader.loadPlacement(placement)
499
+ : await loader(placement)
500
+ return Array.isArray(loaded) ? loaded : [loaded]
501
+ }
502
+
503
+ /**
504
+ * Translates meshes by a fixed offset.
505
+ * @param {object[]} meshes Source meshes.
506
+ * @param {{ x?: number, y?: number, z?: number }} offset Translation.
507
+ * @returns {object[]}
508
+ */
509
+ static #translateMeshes(meshes, offset) {
510
+ return meshes.map((mesh) => ({
511
+ ...mesh,
512
+ vertices: PcbModelArchiveExporter.#array(mesh.vertices).map(
513
+ (vertex) => [
514
+ Number(vertex?.[0] || 0) + Number(offset.x || 0),
515
+ Number(vertex?.[1] || 0) + Number(offset.y || 0),
516
+ Number(vertex?.[2] || 0) + Number(offset.z || 0)
517
+ ]
518
+ )
519
+ }))
520
+ }
521
+
522
+ /**
523
+ * Resolves one component-local origin for a stitched export.
524
+ * @param {{ externalPlacements: object[], staticBodyPlacements: object[] }} candidate Stitched component candidate.
525
+ * @returns {{ x: number, y: number, z: number }}
526
+ */
527
+ static #stitchedAnchor(candidate) {
528
+ const placement =
529
+ candidate.externalPlacements[0] ||
530
+ candidate.staticBodyPlacements[0] ||
531
+ {}
532
+ return {
533
+ x: Number(placement?.positionMil?.x || 0),
534
+ y: Number(placement?.positionMil?.y || 0),
535
+ z: Number(placement?.positionMil?.z || 0)
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Gets or creates one stitched candidate group.
541
+ * @param {Map<string, { designator: string, externalPlacements: object[], staticBodyPlacements: object[] }>} groups Mutable groups.
542
+ * @param {string} designator Component designator.
543
+ * @returns {{ designator: string, externalPlacements: object[], staticBodyPlacements: object[] }}
544
+ */
545
+ static #stitchedGroup(groups, designator) {
546
+ if (!groups.has(designator)) {
547
+ groups.set(designator, {
548
+ designator,
549
+ externalPlacements: [],
550
+ staticBodyPlacements: []
551
+ })
552
+ }
553
+
554
+ return groups.get(designator)
555
+ }
556
+
557
+ /**
558
+ * Builds a designator lookup for scene components.
559
+ * @param {{ components?: object[] } | undefined} sceneDescription Scene data.
560
+ * @returns {Map<string, object>}
561
+ */
562
+ static #componentByDesignator(sceneDescription) {
563
+ const componentByDesignator = new Map()
564
+ PcbModelArchiveExporter.#array(sceneDescription?.components).forEach(
565
+ (component) => {
566
+ const designator = String(component?.designator || '').trim()
567
+ if (designator) {
568
+ componentByDesignator.set(designator, component)
569
+ }
570
+ }
571
+ )
572
+
573
+ return componentByDesignator
574
+ }
575
+
148
576
  /**
149
577
  * Resolves one archive file name for the exported model.
150
578
  * @param {{ pattern: string, model: { format?: string } }} entry
@@ -175,6 +603,37 @@ export class PcbModelArchiveExporter {
175
603
  return archivePath
176
604
  }
177
605
 
606
+ /**
607
+ * Resolves one archive path for a stitched component STEP.
608
+ * @param {{ designator: string }} entry Stitched entry.
609
+ * @param {{ archivePath: string }[]} exportedEntries Existing entries.
610
+ * @returns {string}
611
+ */
612
+ static #resolveStitchedArchivePath(entry, exportedEntries) {
613
+ const sanitizedDesignator = PcbModelArchiveExporter.#sanitizeFileToken(
614
+ entry.designator
615
+ )
616
+ const basePath = 'stitched-components/' + sanitizedDesignator + '.step'
617
+ let archivePath = basePath
618
+ let duplicateIndex = 2
619
+
620
+ while (
621
+ exportedEntries.some(
622
+ (exportedEntry) => exportedEntry.archivePath === archivePath
623
+ )
624
+ ) {
625
+ archivePath =
626
+ 'stitched-components/' +
627
+ sanitizedDesignator +
628
+ '--' +
629
+ duplicateIndex +
630
+ '.step'
631
+ duplicateIndex += 1
632
+ }
633
+
634
+ return archivePath
635
+ }
636
+
178
637
  /**
179
638
  * Reads one resolved model into ZIP-ready bytes.
180
639
  * @param {{ origin?: string, payloadText?: string, file?: Blob | File | null }} model
@@ -279,6 +738,46 @@ export class PcbModelArchiveExporter {
279
738
  return 'step'
280
739
  }
281
740
 
741
+ /**
742
+ * Resolves a normalized RGB color for a static body mesh.
743
+ * @param {{ bodyColor?: object, geometry?: object }} placement Static body placement.
744
+ * @returns {number[]}
745
+ */
746
+ static #bodyColor(placement) {
747
+ const rgb =
748
+ placement?.bodyColor?.rgb || placement?.geometry?.bodyColor?.rgb
749
+ if (rgb) {
750
+ return [
751
+ PcbModelArchiveExporter.#colorChannel(rgb.red),
752
+ PcbModelArchiveExporter.#colorChannel(rgb.green),
753
+ PcbModelArchiveExporter.#colorChannel(rgb.blue)
754
+ ]
755
+ }
756
+
757
+ return STATIC_BODY_COLOR
758
+ }
759
+
760
+ /**
761
+ * Normalizes one 8-bit color channel to [0, 1].
762
+ * @param {number | undefined} channel Source channel.
763
+ * @returns {number}
764
+ */
765
+ static #colorChannel(channel) {
766
+ const value = Number(channel)
767
+ return Number.isFinite(value)
768
+ ? Math.min(1, Math.max(0, value / 255))
769
+ : 0.5
770
+ }
771
+
772
+ /**
773
+ * Normalizes a value to an array.
774
+ * @param {unknown} value Candidate value.
775
+ * @returns {any[]}
776
+ */
777
+ static #array(value) {
778
+ return Array.isArray(value) ? value : []
779
+ }
780
+
282
781
  /**
283
782
  * Removes the trailing extension from one file name.
284
783
  * @param {string} value