pcb-scene3d-viewer 1.1.0 → 1.1.2

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,667 @@
1
+ import { PcbScene3dSelectionInspectorRenderer } from './PcbScene3dSelectionInspectorRenderer.mjs'
2
+
3
+ /**
4
+ * Binds editable component transform controls to controller callbacks.
5
+ */
6
+ export class PcbScene3dAdjustmentControlBinder {
7
+ /** @type {HTMLElement | null} */
8
+ #selectionNode
9
+
10
+ /** @type {HTMLElement | null} */
11
+ #hostNode
12
+
13
+ /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
14
+ #listeners
15
+
16
+ /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
17
+ #repeatStopListeners
18
+
19
+ /** @type {any | null} */
20
+ #repeatStartTimer
21
+
22
+ /** @type {any | null} */
23
+ #repeatIntervalTimer
24
+
25
+ /** @type {() => string} */
26
+ #resolveDesignator
27
+
28
+ /** @type {(designator: string) => { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }} */
29
+ #resolveCurrentAdjustment
30
+
31
+ /** @type {(designator: string) => { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }} */
32
+ #resolveBaselineAdjustment
33
+
34
+ /** @type {(designator: string, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }) => void} */
35
+ #setAdjustment
36
+
37
+ /** @type {(designator: string) => void} */
38
+ #deleteAdjustment
39
+
40
+ /** @type {(designator: string, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }) => void} */
41
+ #applyRuntimeAdjustment
42
+
43
+ /** @type {(suppressed: boolean) => void} */
44
+ #setSelectionHighlightSuppressed
45
+
46
+ /** @type {(designator: string) => void} */
47
+ #refreshSelection
48
+
49
+ /**
50
+ * @param {{ resolveDesignator: () => string, resolveCurrentAdjustment: (designator: string) => { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }, resolveBaselineAdjustment: (designator: string) => { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }, setAdjustment: (designator: string, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }) => void, deleteAdjustment: (designator: string) => void, applyRuntimeAdjustment: (designator: string, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }) => void, setSelectionHighlightSuppressed: (suppressed: boolean) => void, refreshSelection: (designator: string) => void }} options Binder callbacks.
51
+ */
52
+ constructor(options) {
53
+ this.#selectionNode = null
54
+ this.#hostNode = null
55
+ this.#listeners = []
56
+ this.#repeatStopListeners = []
57
+ this.#repeatStartTimer = null
58
+ this.#repeatIntervalTimer = null
59
+ this.#resolveDesignator = options.resolveDesignator
60
+ this.#resolveCurrentAdjustment = options.resolveCurrentAdjustment
61
+ this.#resolveBaselineAdjustment = options.resolveBaselineAdjustment
62
+ this.#setAdjustment = options.setAdjustment
63
+ this.#deleteAdjustment = options.deleteAdjustment
64
+ this.#applyRuntimeAdjustment = options.applyRuntimeAdjustment
65
+ this.#setSelectionHighlightSuppressed =
66
+ options.setSelectionHighlightSuppressed
67
+ this.#refreshSelection = options.refreshSelection
68
+ }
69
+
70
+ /**
71
+ * Binds delegated controls in the selection inspector.
72
+ * @param {HTMLElement | null} selectionNode Inspector node.
73
+ * @returns {void}
74
+ */
75
+ bindSelectionNode(selectionNode) {
76
+ this.#selectionNode =
77
+ selectionNode && typeof selectionNode === 'object'
78
+ ? selectionNode
79
+ : null
80
+ this.#bindNode(this.#selectionNode)
81
+ }
82
+
83
+ /**
84
+ * Replaces the external host node used for transform controls.
85
+ * @param {HTMLElement | null} hostNode External control host.
86
+ * @returns {void}
87
+ */
88
+ setHost(hostNode) {
89
+ const nextHost =
90
+ hostNode && typeof hostNode === 'object' ? hostNode : null
91
+ if (this.#hostNode === nextHost) {
92
+ return
93
+ }
94
+
95
+ this.#unbindNode(this.#hostNode)
96
+ this.#hostNode = nextHost
97
+ this.#bindNode(this.#hostNode)
98
+ }
99
+
100
+ /**
101
+ * Removes all bound event listeners.
102
+ * @returns {void}
103
+ */
104
+ dispose() {
105
+ this.#stopRepeat()
106
+ this.#listeners.forEach(({ node, type, listener }) => {
107
+ node.removeEventListener?.(type, listener)
108
+ })
109
+ this.#listeners = []
110
+ this.#selectionNode = null
111
+ this.#hostNode = null
112
+ }
113
+
114
+ /**
115
+ * Binds delegated input and reset events for one node.
116
+ * @param {HTMLElement | null} node Control container.
117
+ * @returns {void}
118
+ */
119
+ #bindNode(node) {
120
+ if (!node) {
121
+ return
122
+ }
123
+
124
+ const inputListener = (event) => this.#handleInput(event)
125
+ const resetListener = (event) => this.#handleReset(event)
126
+ const focusInListener = (event) => this.#handleFocus(event, true)
127
+ const focusOutListener = (event) => this.#handleFocus(event, false)
128
+ const wheelListener = (event) => this.#handleWheel(event)
129
+ const keyDownListener = (event) => this.#handleKeyDown(event)
130
+ const pointerDownListener = (event) => this.#handlePointerDown(event)
131
+ const stopRepeatListener = () => this.#stopRepeat()
132
+ const bindings = [
133
+ { node, type: 'input', listener: inputListener },
134
+ { node, type: 'change', listener: inputListener },
135
+ { node, type: 'click', listener: resetListener },
136
+ { node, type: 'focusin', listener: focusInListener },
137
+ { node, type: 'focusout', listener: focusOutListener },
138
+ { node, type: 'wheel', listener: wheelListener },
139
+ { node, type: 'keydown', listener: keyDownListener },
140
+ { node, type: 'pointerdown', listener: pointerDownListener },
141
+ { node, type: 'pointerup', listener: stopRepeatListener },
142
+ { node, type: 'pointercancel', listener: stopRepeatListener }
143
+ ]
144
+
145
+ bindings.forEach((binding) => {
146
+ binding.node.addEventListener?.(binding.type, binding.listener)
147
+ this.#listeners.push(binding)
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Removes delegated listeners for one node.
153
+ * @param {HTMLElement | null} node Control container.
154
+ * @returns {void}
155
+ */
156
+ #unbindNode(node) {
157
+ if (!node) {
158
+ return
159
+ }
160
+
161
+ this.#listeners = this.#listeners.filter((binding) => {
162
+ if (binding.node !== node) {
163
+ return true
164
+ }
165
+
166
+ binding.node.removeEventListener?.(binding.type, binding.listener)
167
+ return false
168
+ })
169
+ }
170
+
171
+ /**
172
+ * Handles one transform adjustment input change.
173
+ * @param {{ target?: any }} event Input event.
174
+ * @returns {void}
175
+ */
176
+ #handleInput(event) {
177
+ const input = PcbScene3dSelectionInspectorRenderer.closestInput(
178
+ event?.target
179
+ )
180
+ this.#applyInputValue(input)
181
+ }
182
+
183
+ /**
184
+ * Handles focus changes for transform adjustment inputs.
185
+ * @param {{ target?: any }} event Focus event.
186
+ * @param {boolean} focused Whether the input is focused.
187
+ * @returns {void}
188
+ */
189
+ #handleFocus(event, focused) {
190
+ if (PcbScene3dSelectionInspectorRenderer.closestInput(event?.target)) {
191
+ if (!focused) {
192
+ this.#stopRepeat()
193
+ }
194
+ this.#setSelectionHighlightSuppressed(focused)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Handles one mouse wheel step over a transform adjustment input.
200
+ * @param {{ target?: any, deltaY?: number, preventDefault?: () => void }} event Wheel event.
201
+ * @returns {void}
202
+ */
203
+ #handleWheel(event) {
204
+ const input = PcbScene3dSelectionInspectorRenderer.closestInput(
205
+ event?.target
206
+ )
207
+ if (!input) {
208
+ return
209
+ }
210
+
211
+ this.#stepInput(input, Number(event?.deltaY || 0) < 0 ? 1 : -1)
212
+ event?.preventDefault?.()
213
+ }
214
+
215
+ /**
216
+ * Handles keyboard stepping over a transform adjustment input.
217
+ * @param {{ target?: any, key?: string, preventDefault?: () => void }} event Keyboard event.
218
+ * @returns {void}
219
+ */
220
+ #handleKeyDown(event) {
221
+ const input = PcbScene3dSelectionInspectorRenderer.closestInput(
222
+ event?.target
223
+ )
224
+ if (!input) {
225
+ return
226
+ }
227
+
228
+ const key = String(event?.key || '')
229
+ const direction = key === 'ArrowUp' ? 1 : key === 'ArrowDown' ? -1 : 0
230
+ if (direction === 0) {
231
+ return
232
+ }
233
+
234
+ event?.preventDefault?.()
235
+ this.#stepInput(input, direction)
236
+ }
237
+
238
+ /**
239
+ * Handles a press on the native number input spinner area.
240
+ * @param {{ target?: any, button?: number, clientX?: number, clientY?: number, preventDefault?: () => void }} event Pointer event.
241
+ * @returns {void}
242
+ */
243
+ #handlePointerDown(event) {
244
+ if (Number(event?.button || 0) !== 0) {
245
+ return
246
+ }
247
+
248
+ const stepButton = PcbScene3dAdjustmentControlBinder.#closestStepButton(
249
+ event?.target
250
+ )
251
+ let input =
252
+ PcbScene3dAdjustmentControlBinder.#resolveStepButtonInput(
253
+ stepButton
254
+ )
255
+ let direction =
256
+ PcbScene3dAdjustmentControlBinder.#resolveStepButtonDirection(
257
+ stepButton
258
+ )
259
+ if (!input || direction === 0) {
260
+ input = PcbScene3dSelectionInspectorRenderer.closestInput(
261
+ event?.target
262
+ )
263
+ direction =
264
+ PcbScene3dAdjustmentControlBinder.#resolveSpinnerDirection(
265
+ input,
266
+ event
267
+ )
268
+ }
269
+ if (!input || direction === 0) {
270
+ return
271
+ }
272
+
273
+ event?.preventDefault?.()
274
+ input.focus?.()
275
+ this.#setSelectionHighlightSuppressed(true)
276
+ this.#stopRepeat()
277
+ this.#stepInput(input, direction)
278
+ this.#bindRepeatStopListeners(input)
279
+ this.#repeatStartTimer = globalThis.setTimeout?.(() => {
280
+ this.#repeatStartTimer = null
281
+ this.#repeatIntervalTimer = globalThis.setInterval?.(() => {
282
+ this.#stepInput(input, direction)
283
+ }, 75)
284
+ }, 350)
285
+ }
286
+
287
+ /**
288
+ * Steps one adjustment input by one declared step.
289
+ * @param {any} input Adjustment input.
290
+ * @param {number} direction Positive to increment, negative to decrement.
291
+ * @returns {void}
292
+ */
293
+ #stepInput(input, direction) {
294
+ const declaredStep =
295
+ PcbScene3dAdjustmentControlBinder.#resolveStep(input)
296
+ const step =
297
+ PcbScene3dAdjustmentControlBinder.#resolveCaretStep(input) ||
298
+ declaredStep
299
+ const decimals = PcbScene3dAdjustmentControlBinder.#resolveDecimals(
300
+ input,
301
+ declaredStep
302
+ )
303
+ const previousValue = String(input?.value || '')
304
+ const previousCaret =
305
+ PcbScene3dAdjustmentControlBinder.#resolveCaretPosition(input)
306
+ const currentValue =
307
+ PcbScene3dAdjustmentControlBinder.#resolveInputNumber(input)
308
+ const nextValue = currentValue + Math.sign(direction || 0) * step
309
+ input.value = PcbScene3dAdjustmentControlBinder.#formatInputValue(
310
+ nextValue,
311
+ decimals,
312
+ PcbScene3dAdjustmentControlBinder.#resolveDecimalSeparator(
313
+ previousValue
314
+ )
315
+ )
316
+ PcbScene3dAdjustmentControlBinder.#restoreCaret(
317
+ input,
318
+ previousCaret,
319
+ previousValue,
320
+ input.value
321
+ )
322
+ this.#applyInputValue(input)
323
+ }
324
+
325
+ /**
326
+ * Applies the current value of one transform adjustment input.
327
+ * @param {any | null} input Adjustment input.
328
+ * @returns {void}
329
+ */
330
+ #applyInputValue(input) {
331
+ const designator = String(this.#resolveDesignator() || '').trim()
332
+ if (!input || !designator) {
333
+ return
334
+ }
335
+
336
+ const path = String(
337
+ input?.getAttribute?.('data-scene-3d-adjustment') || ''
338
+ )
339
+ const numericValue =
340
+ PcbScene3dAdjustmentControlBinder.#resolveInputNumber(input)
341
+ if (!Number.isFinite(numericValue)) {
342
+ return
343
+ }
344
+
345
+ const adjustment = PcbScene3dSelectionInspectorRenderer.cloneAdjustment(
346
+ this.#resolveCurrentAdjustment(designator)
347
+ )
348
+ if (
349
+ !PcbScene3dSelectionInspectorRenderer.writePath(
350
+ adjustment,
351
+ path,
352
+ numericValue
353
+ )
354
+ ) {
355
+ return
356
+ }
357
+
358
+ this.#setAdjustment(designator, adjustment)
359
+ this.#applyRuntimeAdjustment(designator, adjustment)
360
+ }
361
+
362
+ /**
363
+ * Resolves the numeric step size for one adjustment input.
364
+ * @param {any} input Adjustment input.
365
+ * @returns {number}
366
+ */
367
+ static #resolveStep(input) {
368
+ const step = Number(input?.getAttribute?.('step') || 1)
369
+ return Number.isFinite(step) && step > 0 ? step : 1
370
+ }
371
+
372
+ /**
373
+ * Resolves the current numeric value from one adjustment input.
374
+ * @param {any} input Adjustment input.
375
+ * @returns {number}
376
+ */
377
+ static #resolveInputNumber(input) {
378
+ const numericValue = Number(String(input?.value || 0).replace(',', '.'))
379
+ return Number.isFinite(numericValue) ? numericValue : 0
380
+ }
381
+
382
+ /**
383
+ * Resolves the decimal place represented by the digit before the caret.
384
+ * @param {any} input Adjustment input.
385
+ * @returns {number | null}
386
+ */
387
+ static #resolveCaretStep(input) {
388
+ const valueText = String(input?.value || '')
389
+ const caretPosition =
390
+ PcbScene3dAdjustmentControlBinder.#resolveCaretPosition(input)
391
+ if (!Number.isFinite(caretPosition)) {
392
+ return null
393
+ }
394
+
395
+ const digitIndex =
396
+ PcbScene3dAdjustmentControlBinder.#findPreviousDigitIndex(
397
+ valueText,
398
+ caretPosition
399
+ )
400
+ if (digitIndex < 0) {
401
+ return null
402
+ }
403
+
404
+ const decimalIndex =
405
+ PcbScene3dAdjustmentControlBinder.#resolveDecimalIndex(valueText)
406
+ const integerEndIndex =
407
+ decimalIndex >= 0 ? decimalIndex : valueText.length
408
+ const exponent =
409
+ decimalIndex >= 0 && digitIndex > decimalIndex
410
+ ? decimalIndex - digitIndex
411
+ : integerEndIndex - digitIndex - 1
412
+ const step = Math.pow(10, exponent)
413
+ return Number.isFinite(step) && step > 0 ? step : null
414
+ }
415
+
416
+ /**
417
+ * Resolves a usable caret position from one input.
418
+ * @param {any} input Adjustment input.
419
+ * @returns {number}
420
+ */
421
+ static #resolveCaretPosition(input) {
422
+ const caretPosition = Number(input?.selectionStart)
423
+ return Number.isFinite(caretPosition) ? caretPosition : Number.NaN
424
+ }
425
+
426
+ /**
427
+ * Finds the digit immediately before the caret, skipping separators.
428
+ * @param {string} valueText Current input text.
429
+ * @param {number} caretPosition Caret position.
430
+ * @returns {number}
431
+ */
432
+ static #findPreviousDigitIndex(valueText, caretPosition) {
433
+ const startIndex = Math.min(
434
+ Math.max(Math.floor(caretPosition) - 1, 0),
435
+ valueText.length - 1
436
+ )
437
+ for (let index = startIndex; index >= 0; index -= 1) {
438
+ if (/\d/.test(valueText[index])) {
439
+ return index
440
+ }
441
+ }
442
+ return -1
443
+ }
444
+
445
+ /**
446
+ * Resolves the first decimal separator index.
447
+ * @param {string} valueText Current input text.
448
+ * @returns {number}
449
+ */
450
+ static #resolveDecimalIndex(valueText) {
451
+ const dotIndex = valueText.indexOf('.')
452
+ const commaIndex = valueText.indexOf(',')
453
+ if (dotIndex < 0) return commaIndex
454
+ if (commaIndex < 0) return dotIndex
455
+ return Math.min(dotIndex, commaIndex)
456
+ }
457
+
458
+ /**
459
+ * Resolves which decimal separator style to preserve.
460
+ * @param {string} valueText Current input text.
461
+ * @returns {string}
462
+ */
463
+ static #resolveDecimalSeparator(valueText) {
464
+ const decimalIndex =
465
+ PcbScene3dAdjustmentControlBinder.#resolveDecimalIndex(valueText)
466
+ return decimalIndex >= 0 && valueText[decimalIndex] === ',' ? ',' : '.'
467
+ }
468
+
469
+ /**
470
+ * Formats one stepped value for display.
471
+ * @param {number} value Numeric value.
472
+ * @param {number} decimals Decimal places.
473
+ * @param {string} decimalSeparator Separator to preserve.
474
+ * @returns {string}
475
+ */
476
+ static #formatInputValue(value, decimals, decimalSeparator) {
477
+ const formatted = value.toFixed(decimals)
478
+ return decimalSeparator === ','
479
+ ? formatted.replace('.', ',')
480
+ : formatted
481
+ }
482
+
483
+ /**
484
+ * Restores the caret after replacing the input value.
485
+ * @param {any} input Adjustment input.
486
+ * @param {number} previousCaret Previous caret position.
487
+ * @param {string} previousValue Previous input text.
488
+ * @param {string} nextValue Next input text.
489
+ * @returns {void}
490
+ */
491
+ static #restoreCaret(input, previousCaret, previousValue, nextValue) {
492
+ if (
493
+ !Number.isFinite(previousCaret) ||
494
+ typeof input?.setSelectionRange !== 'function'
495
+ ) {
496
+ return
497
+ }
498
+
499
+ const nextPosition = Math.max(
500
+ 0,
501
+ Math.min(
502
+ nextValue.length,
503
+ previousCaret + nextValue.length - previousValue.length
504
+ )
505
+ )
506
+ try {
507
+ input.setSelectionRange(nextPosition, nextPosition)
508
+ } catch (_error) {
509
+ // Some native input types reject selection APIs. Text inputs do not.
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Resolves display precision from one input step.
515
+ * @param {any} input Adjustment input.
516
+ * @param {number} step Numeric step.
517
+ * @returns {number}
518
+ */
519
+ static #resolveDecimals(input, step) {
520
+ const stepText = String(input?.getAttribute?.('step') || step)
521
+ const decimalText = stepText.includes('.')
522
+ ? stepText.split('.')[1] || ''
523
+ : ''
524
+ return decimalText.replace(/0+$/, '').length || decimalText.length
525
+ }
526
+
527
+ /**
528
+ * Resolves which native spinner half was pressed.
529
+ * @param {any | null} input Adjustment input.
530
+ * @param {{ clientX?: number, clientY?: number }} event Pointer event.
531
+ * @returns {-1 | 0 | 1}
532
+ */
533
+ static #resolveSpinnerDirection(input, event) {
534
+ const rect = input?.getBoundingClientRect?.()
535
+ const clientX = Number(event?.clientX)
536
+ const clientY = Number(event?.clientY)
537
+ if (!rect || !Number.isFinite(clientX) || !Number.isFinite(clientY)) {
538
+ return 0
539
+ }
540
+
541
+ const left = Number(rect.left ?? rect.x ?? 0)
542
+ const top = Number(rect.top ?? rect.y ?? 0)
543
+ const width = Number(rect.width || 0)
544
+ const height = Number(rect.height || 0)
545
+ const right = Number(rect.right ?? left + width)
546
+ const spinnerWidth = Math.min(36, Math.max(24, width * 0.2))
547
+ if (clientX < right - spinnerWidth) {
548
+ return 0
549
+ }
550
+
551
+ return clientY <= top + height / 2 ? 1 : -1
552
+ }
553
+
554
+ /**
555
+ * Finds a custom step button from an event target.
556
+ * @param {any} target Event target.
557
+ * @returns {any | null}
558
+ */
559
+ static #closestStepButton(target) {
560
+ const closest = target?.closest?.('[data-scene-3d-adjustment-step]')
561
+ return closest ||
562
+ target?.getAttribute?.('data-scene-3d-adjustment-step')
563
+ ? closest || target
564
+ : null
565
+ }
566
+
567
+ /**
568
+ * Resolves the input controlled by one custom step button.
569
+ * @param {any | null} stepButton Custom step button.
570
+ * @returns {any | null}
571
+ */
572
+ static #resolveStepButtonInput(stepButton) {
573
+ return (
574
+ stepButton
575
+ ?.closest?.('.scene-3d__adjustment-row')
576
+ ?.querySelector?.('[data-scene-3d-adjustment]') || null
577
+ )
578
+ }
579
+
580
+ /**
581
+ * Resolves one custom step button direction.
582
+ * @param {any | null} stepButton Custom step button.
583
+ * @returns {-1 | 0 | 1}
584
+ */
585
+ static #resolveStepButtonDirection(stepButton) {
586
+ const direction = String(
587
+ stepButton?.getAttribute?.('data-scene-3d-adjustment-step') || ''
588
+ )
589
+ if (direction === 'up') return 1
590
+ if (direction === 'down') return -1
591
+ return 0
592
+ }
593
+
594
+ /**
595
+ * Binds document-level release events for active repeat stepping.
596
+ * @param {any} input Adjustment input.
597
+ * @returns {void}
598
+ */
599
+ #bindRepeatStopListeners(input) {
600
+ this.#unbindRepeatStopListeners()
601
+ const stopListener = () => this.#stopRepeat()
602
+ const ownerDocument = input?.ownerDocument || globalThis.document
603
+ const ownerWindow = ownerDocument?.defaultView || globalThis.window
604
+ const bindings = [
605
+ [ownerDocument, 'pointerup'],
606
+ [ownerDocument, 'pointercancel'],
607
+ [ownerDocument, 'mouseup'],
608
+ [ownerDocument, 'touchend'],
609
+ [ownerWindow, 'blur']
610
+ ]
611
+
612
+ this.#repeatStopListeners = bindings
613
+ .filter(([node]) => node?.addEventListener)
614
+ .map(([node, type]) => ({ node, type, listener: stopListener }))
615
+ this.#repeatStopListeners.forEach(({ node, type, listener }) => {
616
+ node.addEventListener?.(type, listener)
617
+ })
618
+ }
619
+
620
+ /**
621
+ * Stops any active spinner repeat and removes release listeners.
622
+ * @returns {void}
623
+ */
624
+ #stopRepeat() {
625
+ if (this.#repeatStartTimer) {
626
+ globalThis.clearTimeout?.(this.#repeatStartTimer)
627
+ this.#repeatStartTimer = null
628
+ }
629
+ if (this.#repeatIntervalTimer) {
630
+ globalThis.clearInterval?.(this.#repeatIntervalTimer)
631
+ this.#repeatIntervalTimer = null
632
+ }
633
+ this.#unbindRepeatStopListeners()
634
+ }
635
+
636
+ /**
637
+ * Removes document-level release listeners.
638
+ * @returns {void}
639
+ */
640
+ #unbindRepeatStopListeners() {
641
+ this.#repeatStopListeners.forEach(({ node, type, listener }) => {
642
+ node.removeEventListener?.(type, listener)
643
+ })
644
+ this.#repeatStopListeners = []
645
+ }
646
+
647
+ /**
648
+ * Handles one transform reset click.
649
+ * @param {{ target?: any, preventDefault?: () => void }} event Click event.
650
+ * @returns {void}
651
+ */
652
+ #handleReset(event) {
653
+ const resetButton = event?.target?.closest?.(
654
+ '[data-scene-3d-adjustment-reset]'
655
+ )
656
+ const designator = String(this.#resolveDesignator() || '').trim()
657
+ if (!resetButton || !designator) {
658
+ return
659
+ }
660
+
661
+ event?.preventDefault?.()
662
+ this.#deleteAdjustment(designator)
663
+ const baseline = this.#resolveBaselineAdjustment(designator)
664
+ this.#applyRuntimeAdjustment(designator, baseline)
665
+ this.#refreshSelection(designator)
666
+ }
667
+ }
@@ -6,7 +6,7 @@ import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
6
6
  * Builds the board solid profile, including drilled holes.
7
7
  */
8
8
  export class PcbScene3dBoardShapeFactory {
9
- static #CURVE_SEGMENTS = 12
9
+ static #CURVE_SEGMENTS = 8
10
10
  static #PAD_HOLE_SHAPE_SLOT = 2
11
11
  static #PLATED_WALL_MATERIAL_INDEX = 2
12
12
  static #EDGE_WALL_MATERIAL_INDEX = 1
@@ -7,7 +7,7 @@ import { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
7
7
  * Builds separate solder-mask face sheets for board-assembly rendering.
8
8
  */
9
9
  export class PcbScene3dBoardSolderMaskFactory {
10
- static #CURVE_SEGMENTS = 20
10
+ static #CURVE_SEGMENTS = 8
11
11
  static #OUTER_SAMPLE_POINTS = 160
12
12
  static #DRILL_SAMPLE_POINTS = 72
13
13
  static #EDGE_CLEARANCE_MIL = 0