pcb-scene3d-viewer 1.1.21 → 1.1.31

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.
Files changed (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -0,0 +1,602 @@
1
+ const MM_TO_MIL = 1000 / 25.4
2
+
3
+ /**
4
+ * Parses simple text-based triangle model formats into assembly meshes.
5
+ */
6
+ export class PcbAssemblyTextModelMeshParser {
7
+ /**
8
+ * Parses one STL model from text or binary model metadata.
9
+ * @param {{ name?: string, payloadText?: string, file?: any }} model Model metadata.
10
+ * @returns {Promise<object[]>}
11
+ */
12
+ static async parseStlModel(model) {
13
+ const bytes = await PcbAssemblyTextModelMeshParser.#readModelBytes(
14
+ model,
15
+ 'STL'
16
+ )
17
+ const text = new TextDecoder().decode(bytes)
18
+ const meshes = PcbAssemblyTextModelMeshParser.#isBinaryStl(bytes, text)
19
+ ? PcbAssemblyTextModelMeshParser.#parseBinaryStl(bytes, model)
20
+ : PcbAssemblyTextModelMeshParser.#parseAsciiStl(text, model)
21
+
22
+ if (!meshes.length) {
23
+ throw new Error('No triangle geometry was found in STL.')
24
+ }
25
+ return meshes
26
+ }
27
+
28
+ /**
29
+ * Parses one OBJ model from text model metadata.
30
+ * @param {{ name?: string, payloadText?: string, file?: any }} model Model metadata.
31
+ * @returns {Promise<object[]>}
32
+ */
33
+ static async parseObjModel(model) {
34
+ const text = await PcbAssemblyTextModelMeshParser.#readModelText(
35
+ model,
36
+ 'OBJ'
37
+ )
38
+ const vertices = []
39
+ const faceGroups = new Map()
40
+ const materialLibraries = []
41
+ let activeMaterial = ''
42
+
43
+ String(text || '')
44
+ .split(/\r?\n/u)
45
+ .forEach((line) => {
46
+ const trimmed = line.replace(/#.*/u, '').trim()
47
+ if (trimmed.startsWith('mtllib ')) {
48
+ materialLibraries.push(
49
+ ...trimmed.slice(7).trim().split(/\s+/u).filter(Boolean)
50
+ )
51
+ return
52
+ }
53
+
54
+ if (trimmed.startsWith('usemtl ')) {
55
+ activeMaterial = trimmed.slice(7).trim()
56
+ return
57
+ }
58
+
59
+ if (trimmed.startsWith('v ')) {
60
+ vertices.push(
61
+ PcbAssemblyTextModelMeshParser.#toMilTriplet(
62
+ trimmed.slice(2)
63
+ )
64
+ )
65
+ return
66
+ }
67
+
68
+ if (trimmed.startsWith('f ')) {
69
+ const face = PcbAssemblyTextModelMeshParser.#parseObjFace(
70
+ trimmed.slice(2),
71
+ vertices.length
72
+ )
73
+ if (face.length >= 3) {
74
+ PcbAssemblyTextModelMeshParser.#facesForMaterial(
75
+ faceGroups,
76
+ activeMaterial
77
+ ).push(face)
78
+ }
79
+ }
80
+ })
81
+
82
+ const meshGroups = Array.from(faceGroups.entries()).filter(
83
+ (entry) => entry[1].length
84
+ )
85
+ if (!vertices.length || !meshGroups.length) {
86
+ throw new Error('No polygon geometry was found in OBJ.')
87
+ }
88
+
89
+ const materials =
90
+ await PcbAssemblyTextModelMeshParser.#objMaterialColors(
91
+ model,
92
+ materialLibraries
93
+ )
94
+ const baseName = PcbAssemblyTextModelMeshParser.#modelName(
95
+ model,
96
+ 'obj-model'
97
+ )
98
+
99
+ return meshGroups.map(([materialName, faces], index) => ({
100
+ name: PcbAssemblyTextModelMeshParser.#objMeshName(
101
+ baseName,
102
+ materialName,
103
+ meshGroups.length,
104
+ index
105
+ ),
106
+ vertices,
107
+ faces,
108
+ ...PcbAssemblyTextModelMeshParser.#objMaterialProperties(
109
+ materials.get(materialName)
110
+ )
111
+ }))
112
+ }
113
+
114
+ /**
115
+ * Parses ASCII STL facet vertices.
116
+ * @param {string} text STL source.
117
+ * @param {{ name?: string }} model Model metadata.
118
+ * @returns {object[]}
119
+ */
120
+ static #parseAsciiStl(text, model) {
121
+ const vertices = []
122
+ const faces = []
123
+ const pending = []
124
+
125
+ for (const match of String(text || '').matchAll(
126
+ /^\s*vertex\s+(.+)$/gimu
127
+ )) {
128
+ pending.push(
129
+ PcbAssemblyTextModelMeshParser.#toMilTriplet(match[1] || '')
130
+ )
131
+ if (pending.length === 3) {
132
+ const firstIndex = vertices.length
133
+ vertices.push(...pending.splice(0, 3))
134
+ faces.push([firstIndex, firstIndex + 1, firstIndex + 2])
135
+ }
136
+ }
137
+
138
+ return vertices.length
139
+ ? [
140
+ {
141
+ name: PcbAssemblyTextModelMeshParser.#modelName(
142
+ model,
143
+ 'stl-model'
144
+ ),
145
+ vertices,
146
+ faces
147
+ }
148
+ ]
149
+ : []
150
+ }
151
+
152
+ /**
153
+ * Parses binary STL facet vertices.
154
+ * @param {Uint8Array} bytes STL bytes.
155
+ * @param {{ name?: string }} model Model metadata.
156
+ * @returns {object[]}
157
+ */
158
+ static #parseBinaryStl(bytes, model) {
159
+ if (bytes.byteLength < 84) {
160
+ return []
161
+ }
162
+
163
+ const view = new DataView(
164
+ bytes.buffer,
165
+ bytes.byteOffset,
166
+ bytes.byteLength
167
+ )
168
+ const triangleCount = view.getUint32(80, true)
169
+ const vertices = []
170
+ const faces = []
171
+
172
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
173
+ const offset = 84 + triangle * 50
174
+ if (offset + 50 > bytes.byteLength) {
175
+ break
176
+ }
177
+
178
+ const firstIndex = vertices.length
179
+ for (let vertex = 0; vertex < 3; vertex += 1) {
180
+ const vertexOffset = offset + 12 + vertex * 12
181
+ vertices.push([
182
+ view.getFloat32(vertexOffset, true) * MM_TO_MIL,
183
+ view.getFloat32(vertexOffset + 4, true) * MM_TO_MIL,
184
+ view.getFloat32(vertexOffset + 8, true) * MM_TO_MIL
185
+ ])
186
+ }
187
+ faces.push([firstIndex, firstIndex + 1, firstIndex + 2])
188
+ }
189
+
190
+ return vertices.length
191
+ ? [
192
+ {
193
+ name: PcbAssemblyTextModelMeshParser.#modelName(
194
+ model,
195
+ 'stl-model'
196
+ ),
197
+ vertices,
198
+ faces
199
+ }
200
+ ]
201
+ : []
202
+ }
203
+
204
+ /**
205
+ * Returns true when bytes look like binary STL instead of ASCII STL.
206
+ * @param {Uint8Array} bytes STL bytes.
207
+ * @param {string} text UTF-8 decoded source.
208
+ * @returns {boolean}
209
+ */
210
+ static #isBinaryStl(bytes, text) {
211
+ if (bytes.byteLength < 84) {
212
+ return false
213
+ }
214
+
215
+ const view = new DataView(
216
+ bytes.buffer,
217
+ bytes.byteOffset,
218
+ bytes.byteLength
219
+ )
220
+ const triangleCount = view.getUint32(80, true)
221
+ const expectedLength = 84 + triangleCount * 50
222
+ if (expectedLength === bytes.byteLength) {
223
+ return true
224
+ }
225
+
226
+ return !/^\s*solid\b/iu.test(String(text || ''))
227
+ }
228
+
229
+ /**
230
+ * Parses one OBJ face line into zero-based vertex indexes.
231
+ * @param {string} text OBJ face source.
232
+ * @param {number} vertexCount Number of known vertices.
233
+ * @returns {number[]}
234
+ */
235
+ static #parseObjFace(text, vertexCount) {
236
+ return String(text || '')
237
+ .trim()
238
+ .split(/\s+/u)
239
+ .map((token) => Number(token.split('/')[0]))
240
+ .filter((index) => Number.isInteger(index) && index !== 0)
241
+ .map((index) =>
242
+ index < 0 ? vertexCount + index : Math.max(index - 1, 0)
243
+ )
244
+ .filter((index) => index >= 0 && index < vertexCount)
245
+ }
246
+
247
+ /**
248
+ * Returns the mutable face list for an OBJ material name.
249
+ * @param {Map<string, number[][]>} faceGroups Mutable face groups.
250
+ * @param {string} materialName Active material name.
251
+ * @returns {number[][]}
252
+ */
253
+ static #facesForMaterial(faceGroups, materialName) {
254
+ const key = String(materialName || '')
255
+ if (!faceGroups.has(key)) {
256
+ faceGroups.set(key, [])
257
+ }
258
+ return faceGroups.get(key)
259
+ }
260
+
261
+ /**
262
+ * Reads and parses declared OBJ material libraries.
263
+ * @param {object} model Model metadata.
264
+ * @param {string[]} materialLibraries Declared MTL file names.
265
+ * @returns {Promise<Map<string, number[]>>}
266
+ */
267
+ static async #objMaterialColors(model, materialLibraries) {
268
+ const materials = new Map()
269
+ for (const library of materialLibraries) {
270
+ const text = await PcbAssemblyTextModelMeshParser.#readResourceText(
271
+ library,
272
+ model
273
+ )
274
+ PcbAssemblyTextModelMeshParser.#parseObjMaterialLibrary(
275
+ text,
276
+ materials
277
+ )
278
+ }
279
+ return materials
280
+ }
281
+
282
+ /**
283
+ * Parses one MTL material library into color entries.
284
+ * @param {string | null} text MTL source text.
285
+ * @param {Map<string, number[]>} materials Mutable material color map.
286
+ * @returns {void}
287
+ */
288
+ static #parseObjMaterialLibrary(text, materials) {
289
+ let activeName = ''
290
+ let active = null
291
+
292
+ String(text || '')
293
+ .split(/\r?\n/u)
294
+ .forEach((line) => {
295
+ const trimmed = line.replace(/#.*/u, '').trim()
296
+ if (trimmed.startsWith('newmtl ')) {
297
+ activeName = trimmed.slice(7).trim()
298
+ active = { rgb: null, alpha: 1 }
299
+ if (activeName) {
300
+ materials.set(activeName, [0.55, 0.56, 0.58, 1])
301
+ }
302
+ return
303
+ }
304
+
305
+ if (!active || !activeName) {
306
+ return
307
+ }
308
+
309
+ if (trimmed.startsWith('Kd ')) {
310
+ active.rgb = PcbAssemblyTextModelMeshParser.#unitTriple(
311
+ trimmed.slice(3)
312
+ )
313
+ } else if (trimmed.startsWith('d ')) {
314
+ active.alpha = PcbAssemblyTextModelMeshParser.#clampUnit(
315
+ Number(trimmed.slice(2))
316
+ )
317
+ } else if (trimmed.startsWith('Tr ')) {
318
+ active.alpha =
319
+ 1 -
320
+ PcbAssemblyTextModelMeshParser.#clampUnit(
321
+ Number(trimmed.slice(3))
322
+ )
323
+ }
324
+
325
+ if (active.rgb) {
326
+ materials.set(activeName, [...active.rgb, active.alpha])
327
+ }
328
+ })
329
+ }
330
+
331
+ /**
332
+ * Resolves material properties for an OBJ face group.
333
+ * @param {number[] | undefined} color Material color.
334
+ * @returns {{ color?: number[] }}
335
+ */
336
+ static #objMaterialProperties(color) {
337
+ return Array.isArray(color) && color.length >= 3 ? { color } : {}
338
+ }
339
+
340
+ /**
341
+ * Builds a stable OBJ mesh name.
342
+ * @param {string} baseName Base model name.
343
+ * @param {string} materialName Material group name.
344
+ * @param {number} meshCount Number of mesh groups.
345
+ * @param {number} meshIndex Mesh group index.
346
+ * @returns {string}
347
+ */
348
+ static #objMeshName(baseName, materialName, meshCount, meshIndex) {
349
+ if (meshCount <= 1) {
350
+ return baseName
351
+ }
352
+
353
+ const suffix =
354
+ PcbAssemblyTextModelMeshParser.#safeName(materialName) ||
355
+ String(meshIndex + 1)
356
+ return baseName + '-' + suffix
357
+ }
358
+
359
+ /**
360
+ * Parses a unit RGB triplet.
361
+ * @param {string} text Numeric triplet source.
362
+ * @returns {number[]}
363
+ */
364
+ static #unitTriple(text) {
365
+ const values =
366
+ String(text || '').match(/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/gu) || []
367
+ return [0, 1, 2].map((index) =>
368
+ PcbAssemblyTextModelMeshParser.#clampUnit(values[index])
369
+ )
370
+ }
371
+
372
+ /**
373
+ * Clamps a value into the unit interval.
374
+ * @param {unknown} value Candidate number.
375
+ * @param {number} [fallback] Fallback value.
376
+ * @returns {number}
377
+ */
378
+ static #clampUnit(value, fallback = 0) {
379
+ const number = Number(value)
380
+ return Number.isFinite(number)
381
+ ? Math.min(Math.max(number, 0), 1)
382
+ : fallback
383
+ }
384
+
385
+ /**
386
+ * Converts a numeric triplet from millimeters into internal mils.
387
+ * @param {string} text Numeric triplet source.
388
+ * @returns {number[]}
389
+ */
390
+ static #toMilTriplet(text) {
391
+ const values =
392
+ String(text || '').match(/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/gu) || []
393
+ return [0, 1, 2].map((index) => Number(values[index] || 0) * MM_TO_MIL)
394
+ }
395
+
396
+ /**
397
+ * Reads model metadata as UTF-8 text.
398
+ * @param {{ payloadText?: string, file?: any }} model Model metadata.
399
+ * @param {string} label Format label.
400
+ * @returns {Promise<string>}
401
+ */
402
+ static async #readModelText(model, label) {
403
+ if (typeof model?.payloadText === 'string') {
404
+ return model.payloadText
405
+ }
406
+
407
+ if (typeof model?.file?.text === 'function') {
408
+ return await model.file.text()
409
+ }
410
+
411
+ return new TextDecoder().decode(
412
+ await PcbAssemblyTextModelMeshParser.#readModelBytes(model, label)
413
+ )
414
+ }
415
+
416
+ /**
417
+ * Reads model metadata as bytes.
418
+ * @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, file?: any }} model Model metadata.
419
+ * @param {string} label Format label.
420
+ * @returns {Promise<Uint8Array>}
421
+ */
422
+ static async #readModelBytes(model, label) {
423
+ const bytes = await PcbAssemblyTextModelMeshParser.#readAnyBytes(model)
424
+ if (bytes) {
425
+ return bytes
426
+ }
427
+
428
+ throw new Error(label + ' model content is not available.')
429
+ }
430
+
431
+ /**
432
+ * Reads a sidecar resource as UTF-8 text.
433
+ * @param {string} uri Resource URI from the OBJ file.
434
+ * @param {object} model Model metadata.
435
+ * @returns {Promise<string | null>}
436
+ */
437
+ static async #readResourceText(uri, model) {
438
+ const normalizedUri = PcbAssemblyTextModelMeshParser.#normalizePath(uri)
439
+ const uriName = normalizedUri.split('/').pop()
440
+
441
+ for (const resource of PcbAssemblyTextModelMeshParser.#resources(
442
+ model
443
+ )) {
444
+ const names =
445
+ PcbAssemblyTextModelMeshParser.#resourceNames(resource)
446
+ if (
447
+ names.some(
448
+ (name) =>
449
+ name === normalizedUri ||
450
+ name.endsWith('/' + normalizedUri) ||
451
+ name.split('/').pop() === uriName
452
+ )
453
+ ) {
454
+ const bytes =
455
+ await PcbAssemblyTextModelMeshParser.#readAnyBytes(resource)
456
+ if (bytes) {
457
+ return new TextDecoder().decode(bytes)
458
+ }
459
+ }
460
+ }
461
+
462
+ return null
463
+ }
464
+
465
+ /**
466
+ * Collects sidecar resource candidates from model metadata.
467
+ * @param {object} model Model metadata.
468
+ * @returns {object[]}
469
+ */
470
+ static #resources(model) {
471
+ const resources = []
472
+ ;[
473
+ model?.resources,
474
+ model?.relatedFiles,
475
+ model?.assets,
476
+ model?.files,
477
+ model?.externalBuffers,
478
+ model?.bufferFiles
479
+ ].forEach((value) => {
480
+ if (Array.isArray(value)) {
481
+ resources.push(...value)
482
+ } else if (value && typeof value === 'object') {
483
+ Object.entries(value).forEach(([name, entry]) => {
484
+ resources.push(
485
+ typeof entry === 'string'
486
+ ? { name, payloadText: entry }
487
+ : { name, ...(entry || {}) }
488
+ )
489
+ })
490
+ }
491
+ })
492
+ return resources
493
+ }
494
+
495
+ /**
496
+ * Returns normalized resource names used for URI matching.
497
+ * @param {object} resource Resource metadata.
498
+ * @returns {string[]}
499
+ */
500
+ static #resourceNames(resource) {
501
+ return [
502
+ resource?.uri,
503
+ resource?.relativePath,
504
+ resource?.name,
505
+ resource?.file?.name,
506
+ resource?.sourceUrl
507
+ ]
508
+ .map((value) =>
509
+ PcbAssemblyTextModelMeshParser.#normalizePath(value)
510
+ )
511
+ .filter(Boolean)
512
+ }
513
+
514
+ /**
515
+ * Reads byte-like values from common model metadata shapes.
516
+ * @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, data?: any, file?: any }} source Source metadata.
517
+ * @returns {Promise<Uint8Array | null>}
518
+ */
519
+ static async #readAnyBytes(source) {
520
+ if (typeof source?.payloadText === 'string') {
521
+ return new TextEncoder().encode(source.payloadText)
522
+ }
523
+
524
+ for (const value of [
525
+ source?.payloadBytes,
526
+ source?.bytes,
527
+ source?.data,
528
+ source?.file,
529
+ source
530
+ ]) {
531
+ const bytes =
532
+ await PcbAssemblyTextModelMeshParser.#bytesFromValue(value)
533
+ if (bytes) {
534
+ return bytes
535
+ }
536
+ }
537
+
538
+ return null
539
+ }
540
+
541
+ /**
542
+ * Converts one byte-like value to a Uint8Array.
543
+ * @param {any} value Candidate value.
544
+ * @returns {Promise<Uint8Array | null>}
545
+ */
546
+ static async #bytesFromValue(value) {
547
+ if (!value) {
548
+ return null
549
+ }
550
+ if (value instanceof Uint8Array) {
551
+ return value
552
+ }
553
+ if (value instanceof ArrayBuffer) {
554
+ return new Uint8Array(value)
555
+ }
556
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
557
+ return new Uint8Array(
558
+ value.buffer,
559
+ value.byteOffset,
560
+ value.byteLength
561
+ )
562
+ }
563
+ if (typeof value.arrayBuffer === 'function') {
564
+ return new Uint8Array(await value.arrayBuffer())
565
+ }
566
+ return null
567
+ }
568
+
569
+ /**
570
+ * Resolves a mesh name.
571
+ * @param {{ name?: string }} model Model metadata.
572
+ * @param {string} fallback Fallback name.
573
+ * @returns {string}
574
+ */
575
+ static #modelName(model, fallback) {
576
+ return String(model?.name || fallback || 'model')
577
+ }
578
+
579
+ /**
580
+ * Normalizes a path-like value for matching.
581
+ * @param {unknown} value Path candidate.
582
+ * @returns {string}
583
+ */
584
+ static #normalizePath(value) {
585
+ return String(value || '')
586
+ .split(/[?#]/u)[0]
587
+ .replace(/\\/gu, '/')
588
+ .replace(/^\/+/u, '')
589
+ .toLowerCase()
590
+ }
591
+
592
+ /**
593
+ * Builds a safe identifier segment.
594
+ * @param {unknown} value Candidate name.
595
+ * @returns {string}
596
+ */
597
+ static #safeName(value) {
598
+ return String(value || '')
599
+ .replace(/[^A-Za-z0-9_.-]+/gu, '_')
600
+ .replace(/^_+|_+$/gu, '')
601
+ }
602
+ }