@viamrobotics/motion-tools 1.33.0 → 1.33.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.
Files changed (60) hide show
  1. package/dist/components/Entities/Entities.svelte +18 -25
  2. package/dist/components/Entities/Entities.svelte.d.ts +2 -17
  3. package/dist/components/Entities/Label.svelte +79 -13
  4. package/dist/components/Entities/Label.svelte.d.ts +2 -1
  5. package/dist/components/Entities/Labels.svelte +36 -0
  6. package/dist/components/Entities/Labels.svelte.d.ts +3 -0
  7. package/dist/components/Entities/LineDots.svelte +8 -3
  8. package/dist/components/Entities/labelLayout/applyTeleports.d.ts +9 -0
  9. package/dist/components/Entities/labelLayout/applyTeleports.js +39 -0
  10. package/dist/components/Entities/labelLayout/buildNeighborhood.d.ts +8 -0
  11. package/dist/components/Entities/labelLayout/buildNeighborhood.js +26 -0
  12. package/dist/components/Entities/labelLayout/cameraHash.d.ts +8 -0
  13. package/dist/components/Entities/labelLayout/cameraHash.js +25 -0
  14. package/dist/components/Entities/labelLayout/cost.d.ts +44 -0
  15. package/dist/components/Entities/labelLayout/cost.js +126 -0
  16. package/dist/components/Entities/labelLayout/createLabelLayout.d.ts +27 -0
  17. package/dist/components/Entities/labelLayout/createLabelLayout.js +194 -0
  18. package/dist/components/Entities/labelLayout/geometry.d.ts +20 -0
  19. package/dist/components/Entities/labelLayout/geometry.js +151 -0
  20. package/dist/components/Entities/labelLayout/labelStore.svelte.d.ts +17 -0
  21. package/dist/components/Entities/labelLayout/labelStore.svelte.js +28 -0
  22. package/dist/components/Entities/labelLayout/measure.d.ts +13 -0
  23. package/dist/components/Entities/labelLayout/measure.js +42 -0
  24. package/dist/components/Entities/labelLayout/slots.d.ts +11 -0
  25. package/dist/components/Entities/labelLayout/slots.js +47 -0
  26. package/dist/components/Entities/labelLayout/solve.d.ts +11 -0
  27. package/dist/components/Entities/labelLayout/solve.js +93 -0
  28. package/dist/components/Entities/labelLayout/spatialHash.d.ts +15 -0
  29. package/dist/components/Entities/labelLayout/spatialHash.js +53 -0
  30. package/dist/components/Entities/labelLayout/types.d.ts +105 -0
  31. package/dist/components/Entities/labelLayout/types.js +19 -0
  32. package/dist/components/Entities/labelLayout/writeBack.d.ts +20 -0
  33. package/dist/components/Entities/labelLayout/writeBack.js +51 -0
  34. package/dist/components/Scene.svelte +2 -1
  35. package/dist/components/SelectedTransformControls.svelte +65 -47
  36. package/dist/components/overlay/Details.svelte +210 -226
  37. package/dist/components/overlay/Details.svelte.d.ts +1 -1
  38. package/dist/components/overlay/Popover.svelte +6 -4
  39. package/dist/components/overlay/Popover.svelte.d.ts +6 -2
  40. package/dist/components/overlay/dashboard/Button.svelte +7 -2
  41. package/dist/components/overlay/dashboard/Button.svelte.d.ts +2 -1
  42. package/dist/components/overlay/details/AxesHelperDetails.svelte +32 -0
  43. package/dist/components/overlay/details/AxesHelperDetails.svelte.d.ts +7 -0
  44. package/dist/components/overlay/details/ColorDetails.svelte +35 -0
  45. package/dist/components/overlay/details/ColorDetails.svelte.d.ts +7 -0
  46. package/dist/components/overlay/details/GeometryDetails.svelte +104 -0
  47. package/dist/components/overlay/details/GeometryDetails.svelte.d.ts +7 -0
  48. package/dist/components/overlay/details/LineDetails/LineDetails.svelte +196 -0
  49. package/dist/components/overlay/details/LineDetails/LineDetails.svelte.d.ts +7 -0
  50. package/dist/components/overlay/details/LineDetails/linePositions.d.ts +3 -0
  51. package/dist/components/overlay/details/LineDetails/linePositions.js +30 -0
  52. package/dist/components/overlay/details/OpacityDetails.svelte +44 -0
  53. package/dist/components/overlay/details/OpacityDetails.svelte.d.ts +7 -0
  54. package/dist/components/overlay/details/PoseDetails.svelte +189 -0
  55. package/dist/components/overlay/details/PoseDetails.svelte.d.ts +14 -0
  56. package/dist/ecs/traits.d.ts +1 -1
  57. package/dist/ecs/traits.js +1 -1
  58. package/dist/hooks/usePartConfig.svelte.js +8 -6
  59. package/dist/hooks/useWorldState.svelte.js +94 -69
  60. package/package.json +4 -2
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Uniform grid over label anchors, used to prune the O(n^2) cost evaluation to a
3
+ * bounded neighborhood. The cell size is chosen so any two labels whose boxes
4
+ * could possibly interact share or border a cell, so scanning the 3x3 block
5
+ * around a node finds every candidate.
6
+ */
7
+ const KEY_OFFSET = 2048;
8
+ const KEY_STRIDE = 4096;
9
+ export class SpatialHash {
10
+ cell = 1;
11
+ buckets = new Map();
12
+ static key(gx, gy) {
13
+ return (gx + KEY_OFFSET) * KEY_STRIDE + (gy + KEY_OFFSET);
14
+ }
15
+ build(nodes, cell) {
16
+ this.cell = Math.max(cell, 1);
17
+ this.buckets.clear();
18
+ for (const node of nodes) {
19
+ const k = SpatialHash.key(Math.floor(node.ax / this.cell), Math.floor(node.ay / this.cell));
20
+ const bucket = this.buckets.get(k);
21
+ if (bucket)
22
+ bucket.push(node);
23
+ else
24
+ this.buckets.set(k, [node]);
25
+ }
26
+ }
27
+ /** Nearest `max` nodes (by anchor distance) in the 3x3 cell block around `node`, excluding itself. */
28
+ queryNeighbors(node, max) {
29
+ const gx = Math.floor(node.ax / this.cell);
30
+ const gy = Math.floor(node.ay / this.cell);
31
+ const found = [];
32
+ for (let ox = -1; ox <= 1; ox++) {
33
+ for (let oy = -1; oy <= 1; oy++) {
34
+ const bucket = this.buckets.get(SpatialHash.key(gx + ox, gy + oy));
35
+ if (!bucket)
36
+ continue;
37
+ for (const other of bucket) {
38
+ if (other !== node)
39
+ found.push(other);
40
+ }
41
+ }
42
+ }
43
+ if (found.length <= max)
44
+ return found;
45
+ found.sort((a, b) => {
46
+ const da = (a.ax - node.ax) ** 2 + (a.ay - node.ay) ** 2;
47
+ const db = (b.ax - node.ax) ** 2 + (b.ay - node.ay) ** 2;
48
+ return da - db;
49
+ });
50
+ found.length = max;
51
+ return found;
52
+ }
53
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Shared types for the label layout engine.
3
+ *
4
+ * All spatial fields are in viewport pixels (one shared screen-space for every
5
+ * label) except where noted. Slots store dot-relative offsets so a camera pan
6
+ * never invalidates them.
7
+ */
8
+ /** Axis-aligned rectangle as center + half-extents (viewport px). */
9
+ export interface Rect {
10
+ cx: number;
11
+ cy: number;
12
+ hw: number;
13
+ hh: number;
14
+ }
15
+ /** Line segment (viewport px). */
16
+ export interface Segment {
17
+ x1: number;
18
+ y1: number;
19
+ x2: number;
20
+ y2: number;
21
+ }
22
+ /** A candidate placement for a label box, expressed as the box-center offset from the dot. */
23
+ export interface Slot {
24
+ /** Box-center offset from the dot, viewport px. */
25
+ dx: number;
26
+ dy: number;
27
+ /** Direction of the slot from the dot, radians. */
28
+ angle: number;
29
+ /** Distance from the dot to the box center, viewport px. */
30
+ radius: number;
31
+ /** Ring index (0 = innermost). */
32
+ ring: number;
33
+ /** Length-only cost (`W.len * radius`); slots are sorted ascending by this for early-out. */
34
+ baseCost: number;
35
+ }
36
+ export interface LabelNode {
37
+ /** Stable per-element id, assigned once. */
38
+ id: string;
39
+ /** FNV hash of `id`, used for deterministic tie-breaks and per-node slot phase. */
40
+ idHash: number;
41
+ ax: number;
42
+ ay: number;
43
+ dotR: number;
44
+ w: number;
45
+ h: number;
46
+ /** Per-island CSS scale (screenPx / localPx). */
47
+ scale: number;
48
+ /** Cached computed CSS width of the dot (local px); read once. */
49
+ cssDotW: number;
50
+ /** Dot center relative to the island origin, island-local px. Fixed CSS offset, measured once (NaN until then). */
51
+ dotLocalX: number;
52
+ dotLocalY: number;
53
+ slots: Slot[];
54
+ /** Hash of the box/dot geometry; slots regenerate only when this or `crowded` changes. */
55
+ geomKey: string;
56
+ crowded: boolean;
57
+ /** Committed slot (target). -1 before first placement. */
58
+ slotIndex: number;
59
+ /** Slot committed by the previous solve, for stickiness. */
60
+ prevSlotIndex: number;
61
+ cx: number;
62
+ cy: number;
63
+ tx: number;
64
+ ty: number;
65
+ settled: boolean;
66
+ /** Conflict cost of the current slot — the local-search loop key. */
67
+ conflict: number;
68
+ /** Locked at a local optimum for the current solve. */
69
+ locked: boolean;
70
+ /** Local cluster centroid (self + neighbors) for the outward-fan term. */
71
+ centroidX: number;
72
+ centroidY: number;
73
+ /** Anchor at the previous solve, for teleport detection. NaN before first solve. */
74
+ prevAx: number;
75
+ prevAy: number;
76
+ /** Pruned interaction set for the current solve (symmetric). */
77
+ neighbors: LabelNode[];
78
+ labelEl: HTMLElement;
79
+ textEl: HTMLElement;
80
+ dotEl: HTMLElement;
81
+ lineEl: SVGLineElement;
82
+ }
83
+ export interface SolverConfig {
84
+ /** Label-label clearance (viewport px). */
85
+ labelPadding: number;
86
+ /** Dot clearance (viewport px). */
87
+ dotPadding: number;
88
+ /** Candidate angles per ring (doubled for crowded nodes). */
89
+ anglesPerRing: number;
90
+ /** Ring radius multipliers for sparse nodes. */
91
+ ringRadii: number[];
92
+ /** Ring radius multipliers for crowded nodes (more escape room). */
93
+ ringRadiiCrowded: number[];
94
+ /** Max accepted local-search moves per solve. */
95
+ polishBudget: number;
96
+ /** Max neighbors considered per node (caps cost in dense clusters). */
97
+ maxNeighbors: number;
98
+ /** Distance (viewport px) under which a box is considered arrived. */
99
+ settleEps: number;
100
+ /** Neighbor count above which a node is "crowded". */
101
+ crowdedThreshold: number;
102
+ /** Fraction of the viewport diagonal that counts as a camera teleport. */
103
+ teleportFrac: number;
104
+ }
105
+ export declare const defaultSolverConfig: SolverConfig;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared types for the label layout engine.
3
+ *
4
+ * All spatial fields are in viewport pixels (one shared screen-space for every
5
+ * label) except where noted. Slots store dot-relative offsets so a camera pan
6
+ * never invalidates them.
7
+ */
8
+ export const defaultSolverConfig = {
9
+ labelPadding: 6,
10
+ dotPadding: 6,
11
+ anglesPerRing: 12,
12
+ ringRadii: [1, 1.55, 2.2],
13
+ ringRadiiCrowded: [1, 1.45, 1.95, 2.6],
14
+ polishBudget: 240,
15
+ maxNeighbors: 24,
16
+ settleEps: 0.4,
17
+ crowdedThreshold: 8,
18
+ teleportFrac: 0.5,
19
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Per-frame animation and DOM write-back. The solver produces a target box
3
+ * center; the animated center eases toward it (framerate-independent), and the
4
+ * eased position is written to the label as a scale-corrected local transform
5
+ * plus leader-line endpoints — the same coordinate handling the old engine used.
6
+ */
7
+ import type { LabelNode } from './types';
8
+ /**
9
+ * Ease `node`'s animated center toward its target. Returns true while still
10
+ * moving, false once arrived (and snapped). `delta` is seconds, pre-clamped by
11
+ * the engine so a long idle gap can't produce an overshoot.
12
+ */
13
+ export declare function lerpStep(node: LabelNode, delta: number, settleEps: number): boolean;
14
+ /**
15
+ * Write the eased position to the DOM. The label island is positioned at the dot
16
+ * by Threlte's <HTML>, so we work in island-local px: convert the viewport-space
17
+ * offset by the island's CSS scale, place the text box, and draw the leader from
18
+ * the dot's island-local position to the box center.
19
+ */
20
+ export declare const writeBack: (node: LabelNode) => void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Per-frame animation and DOM write-back. The solver produces a target box
3
+ * center; the animated center eases toward it (framerate-independent), and the
4
+ * eased position is written to the label as a scale-corrected local transform
5
+ * plus leader-line endpoints — the same coordinate handling the old engine used.
6
+ */
7
+ /** Easing time constant (seconds): ~3-4 frames to arrive at 60fps. */
8
+ const TAU = 0.08;
9
+ /**
10
+ * Ease `node`'s animated center toward its target. Returns true while still
11
+ * moving, false once arrived (and snapped). `delta` is seconds, pre-clamped by
12
+ * the engine so a long idle gap can't produce an overshoot.
13
+ */
14
+ export function lerpStep(node, delta, settleEps) {
15
+ const dx = node.tx - node.cx;
16
+ const dy = node.ty - node.cy;
17
+ if (dx * dx + dy * dy <= settleEps * settleEps) {
18
+ node.cx = node.tx;
19
+ node.cy = node.ty;
20
+ node.settled = true;
21
+ return false;
22
+ }
23
+ const alpha = 1 - Math.exp(-delta / TAU);
24
+ node.cx += dx * alpha;
25
+ node.cy += dy * alpha;
26
+ node.settled = false;
27
+ return true;
28
+ }
29
+ /**
30
+ * Write the eased position to the DOM. The label island is positioned at the dot
31
+ * by Threlte's <HTML>, so we work in island-local px: convert the viewport-space
32
+ * offset by the island's CSS scale, place the text box, and draw the leader from
33
+ * the dot's island-local position to the box center.
34
+ */
35
+ export const writeBack = (node) => {
36
+ const inv = 1 / node.scale;
37
+ // Box-center offset from the dot, in island-local px.
38
+ const dx = (node.cx - node.ax) * inv;
39
+ const dy = (node.cy - node.ay) * inv;
40
+ const wL = node.w * inv;
41
+ const hL = node.h * inv;
42
+ // The dot may not sit at the island origin, so anchor everything at the dot's
43
+ // measured local position rather than assuming (0, 0).
44
+ const ox = node.dotLocalX;
45
+ const oy = node.dotLocalY;
46
+ node.textEl.style.transform = `translate(${ox + dx - wL / 2}px, ${oy + dy - hL / 2}px)`;
47
+ node.lineEl.setAttribute('x1', `${ox}`);
48
+ node.lineEl.setAttribute('y1', `${oy}`);
49
+ node.lineEl.setAttribute('x2', `${ox + dx}`);
50
+ node.lineEl.setAttribute('y2', `${oy + dy}`);
51
+ };
@@ -49,7 +49,8 @@
49
49
  const bvhEnabled = $derived(
50
50
  settings.current.renderSubEntityHoverDetail ||
51
51
  settings.current.interactionMode === 'measure' ||
52
- settings.current.interactionMode === 'select'
52
+ settings.current.interactionMode === 'select' ||
53
+ settings.current.interactionMode === 'gizmo'
53
54
  )
54
55
 
55
56
  bvh(raycaster, () => ({ helper: false, enabled: bvhEnabled }))
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { useThrelte } from '@threlte/core'
3
3
  import { TransformControls } from '@threlte/extras'
4
- import { Matrix4, Quaternion, Vector3 } from 'three'
4
+ import { Matrix4 } from 'three'
5
5
 
6
6
  import type { FrameEditSession } from '../editing/FrameEditSession'
7
7
 
@@ -11,14 +11,7 @@
11
11
  import { useFrameEditSession } from '../hooks/useFrameEditSession.svelte'
12
12
  import { usePartConfig } from '../hooks/usePartConfig.svelte'
13
13
  import { useSettings } from '../hooks/useSettings.svelte'
14
- import {
15
- createPose,
16
- matrixToPose,
17
- poseToMatrix,
18
- quaternionToPose,
19
- solveEditedMatrix,
20
- vector3ToPose,
21
- } from '../transform'
14
+ import { createPose, matrixToPose, poseToMatrix, solveEditedMatrix } from '../transform'
22
15
 
23
16
  const { scene } = useThrelte()
24
17
  const settings = useSettings()
@@ -64,9 +57,10 @@
64
57
  })
65
58
  const isSphereScale = $derived(activeMode === 'scale' && sphere.current !== undefined)
66
59
  const isCapsuleScale = $derived(activeMode === 'scale' && capsule.current !== undefined)
60
+ const transforming = $derived(
61
+ ref && entity && activeMode && !isFragmentComponentWithVariables && !invisible.current
62
+ )
67
63
 
68
- const quaternion = new Quaternion()
69
- const vector3 = new Vector3()
70
64
  const refPose = createPose()
71
65
  const tempRefMatrix = new Matrix4()
72
66
  const tempEditedMatrix = new Matrix4()
@@ -119,28 +113,14 @@
119
113
  }
120
114
 
121
115
  const onChange = () => {
122
- if (!ref || !entity || !activeMode) {
123
- return
124
- }
116
+ if (!ref || !entity || !activeMode) return
125
117
 
126
118
  const isFrameEntity = entity.has(traits.FramesAPI)
127
-
128
119
  if (activeMode === 'translate' || activeMode === 'rotate') {
129
120
  if (isFrameEntity) {
130
121
  stageFrameTransform()
131
122
  } else {
132
- const matrix = entity.get(traits.Matrix)
133
- if (matrix) {
134
- matrixToPose(matrix, tempPose)
135
- if (activeMode === 'translate') {
136
- vector3ToPose(ref.getWorldPosition(vector3), tempPose)
137
- } else {
138
- quaternionToPose(ref.getWorldQuaternion(quaternion), tempPose)
139
- ref.quaternion.copy(quaternion)
140
- }
141
- poseToMatrix(tempPose, matrix)
142
- entity.changed(traits.Matrix)
143
- }
123
+ stageLocalTransform()
144
124
  }
145
125
  } else {
146
126
  // scale → bake the gizmo's scale factor into the geometry trait,
@@ -194,40 +174,46 @@
194
174
  }
195
175
 
196
176
  /**
197
- * Frame.svelte renders frame entities by writing the entity's WorldMatrix
198
- * into group.matrix and decomposing it into position/quaternion. The gizmo's
199
- * Three.js parent has identity world, so `ref.position` / `ref.quaternion`
200
- * are world-space values. Matrix and EditedMatrix store local-to-parent
201
- * transforms, so we left-multiply by the parent's inverted WorldMatrix
202
- * before staging — otherwise WorldMatrix recomposition (parent × edited)
203
- * re-applies the parent's rotation/translation and the frame ends up at
204
- * parent × where-the-user-pulled-it.
177
+ * Build the entity's parent-relative drag target from the gizmo's world-space
178
+ * `ref` transform into `out`.
205
179
  *
206
- * With a kinematic offset (LiveMatrix + Matrix both present), the local
207
- * target M(local) feeds solveEditedMatrix to back out the EditedMatrix
208
- * that satisfies live × baseline⁻¹ × edited = local.
180
+ * Entity renderers mount at the scene root with `matrixAutoUpdate = false`
181
+ * and recompose `group.matrix` from the `WorldMatrix` trait, so
182
+ * `ref.position` / `ref.quaternion` are world-space. Matrix-shaped traits
183
+ * store local-to-parent, so we left-multiply by the parent's inverted
184
+ * WorldMatrix. Otherwise recomposition (parentWorld × local) re-applies the
185
+ * parent transform and the entity lands at parentWorld × where-it-was-dragged.
209
186
  */
210
- const stageFrameTransform = () => {
187
+ const computeLocalDragTarget = (out: Matrix4) => {
211
188
  if (!ref || !entity) return
212
189
 
213
- tempRefMatrix.makeRotationFromQuaternion(ref.quaternion)
214
- tempRefMatrix.setPosition(ref.position)
190
+ out.makeRotationFromQuaternion(ref.quaternion)
191
+ out.setPosition(ref.position)
215
192
 
216
- const parentEntity = entity.targetFor(relations.ChildOf)
217
- const parentWorld = parentEntity?.get(traits.WorldMatrix)
193
+ const parentWorld = entity.targetFor(relations.ChildOf)?.get(traits.WorldMatrix)
218
194
  if (parentWorld) {
219
195
  tempParentInverse.copy(parentWorld).invert()
220
- tempRefMatrix.premultiply(tempParentInverse)
196
+ out.premultiply(tempParentInverse)
221
197
  }
198
+ }
199
+
200
+ /**
201
+ * Stages a translate/rotate drag for a frame system entity into the edit
202
+ * session. With a kinematic offset (LiveMatrix + Matrix both present), the
203
+ * parent-relative target feeds solveEditedMatrix to back out the EditedMatrix
204
+ * satisfying live × baseline⁻¹ × edited = local. Without one, Frame.svelte's
205
+ * blend short-circuits to EditedMatrix, so we stage the target pose directly.
206
+ */
207
+ const stageFrameTransform = () => {
208
+ if (!ref || !entity) return
222
209
 
210
+ computeLocalDragTarget(tempRefMatrix)
223
211
  matrixToPose(tempRefMatrix, refPose)
224
212
 
225
213
  const live = liveMatrix.current
226
214
  const config = configMatrix.current
227
215
 
228
216
  if (!live || !config) {
229
- // No live matrix available — Frame.svelte's blend short-circuits to
230
- // editedMatrix, so the parent-relative target is what we stage.
231
217
  if (activeMode === 'translate') {
232
218
  session?.stagePose(entity, {
233
219
  x: refPose.x,
@@ -249,9 +235,41 @@
249
235
  matrixToPose(tempEditedMatrix, tempPose)
250
236
  session?.stagePose(entity, { ...tempPose })
251
237
  }
238
+
239
+ /**
240
+ * Stages a translate/rotate drag for a non-frame-system entity (e.g. a gizmo)
241
+ * by writing the dragged component into the Matrix trait. Gizmos carry no
242
+ * LiveMatrix, so there's no live-pose blend to invert — the parent-relative
243
+ * target is the new local transform.
244
+ */
245
+ const stageLocalTransform = () => {
246
+ if (!ref || !entity) return
247
+
248
+ const matrix = entity.get(traits.Matrix)
249
+ if (!matrix) return
250
+
251
+ computeLocalDragTarget(tempRefMatrix)
252
+
253
+ // update only the dragged component
254
+ matrixToPose(matrix, tempPose)
255
+ matrixToPose(tempRefMatrix, refPose)
256
+ if (activeMode === 'translate') {
257
+ tempPose.x = refPose.x
258
+ tempPose.y = refPose.y
259
+ tempPose.z = refPose.z
260
+ } else {
261
+ tempPose.oX = refPose.oX
262
+ tempPose.oY = refPose.oY
263
+ tempPose.oZ = refPose.oZ
264
+ tempPose.theta = refPose.theta
265
+ }
266
+
267
+ poseToMatrix(tempPose, matrix)
268
+ entity.changed(traits.Matrix)
269
+ }
252
270
  </script>
253
271
 
254
- {#if ref && entity && activeMode && !isFragmentComponentWithVariables && !invisible.current}
272
+ {#if transforming}
255
273
  {#key entity}
256
274
  <TransformControls
257
275
  object={ref}