pcb-scene3d-viewer 1.1.1 → 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.
- package/docs/api.md +10 -0
- package/package.json +2 -2
- package/src/PcbScene3dAdjustmentControlBinder.mjs +667 -0
- package/src/PcbScene3dComponentAdjustment.mjs +174 -0
- package/src/PcbScene3dComponentAdjustmentRegistry.mjs +95 -0
- package/src/PcbScene3dController.mjs +214 -197
- package/src/PcbScene3dCopperFactory.mjs +4 -1
- package/src/PcbScene3dCutoutGeometryFilter.mjs +9 -3
- package/src/PcbScene3dExternalModels.mjs +56 -71
- package/src/PcbScene3dGeometryBoundsResolver.mjs +78 -0
- package/src/PcbScene3dModelBounds.mjs +267 -0
- package/src/PcbScene3dModelSearchPlacement.mjs +40 -0
- package/src/PcbScene3dPadFactory.mjs +221 -20
- package/src/PcbScene3dRenderGroupVisibility.mjs +23 -1
- package/src/PcbScene3dRuntime.mjs +62 -51
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +581 -0
- package/src/PcbScene3dSelectionResolver.mjs +42 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +4 -1
- package/src/PcbScene3dText.mjs +3 -0
- package/src/styles/scene3d.css +142 -0
|
@@ -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
|
+
}
|