@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
@@ -2,12 +2,13 @@
2
2
  import { Not, Or } from 'koota'
3
3
 
4
4
  import { traits, useQuery } from '../../ecs'
5
+ import { useSettings } from '../../hooks/useSettings.svelte'
5
6
 
6
7
  import Arrows from './Arrows/ArrowGroups.svelte'
7
8
  import Frame from './Frame.svelte'
8
9
  import Geometry from './Geometry.svelte'
9
10
  import GLTF from './GLTF.svelte'
10
- import Label from './Label.svelte'
11
+ import Labels from './Labels.svelte'
11
12
  import Line from './Line.svelte'
12
13
  import Points from './Points.svelte'
13
14
  import Pose from './Pose.svelte'
@@ -60,56 +61,48 @@
60
61
  const points = useQuery(traits.Points)
61
62
  const lines = useQuery(traits.LinePositions)
62
63
  const gltfs = useQuery(traits.GLTF)
64
+
65
+ const settings = useSettings()
66
+
67
+ const enableLabels = $derived(settings.current.enableLabels)
63
68
  </script>
64
69
 
65
70
  {#each machineFramesEntities.current as entity (entity)}
66
71
  <Pose {entity}>
67
- <Frame {entity}>
68
- <Label text={entity.get(traits.Name)} />
69
- </Frame>
72
+ <Frame {entity} />
70
73
  </Pose>
71
74
  {/each}
72
75
 
73
76
  {#each resourceGeometriesEntities.current as entity (entity)}
74
- <Geometry {entity}>
75
- <Label text={entity.get(traits.Name)} />
76
- </Geometry>
77
+ <Geometry {entity} />
77
78
  {/each}
78
79
 
79
80
  {#each worldStateEntities.current as entity (entity)}
80
- <Frame {entity}>
81
- <Label text={entity.get(traits.Name)} />
82
- </Frame>
81
+ <Frame {entity} />
83
82
  {/each}
84
83
 
85
84
  {#each drawServiceEntities.current as entity (entity)}
86
- <Frame {entity}>
87
- <Label text={entity.get(traits.Name)} />
88
- </Frame>
85
+ <Frame {entity} />
89
86
  {/each}
90
87
 
91
88
  {#each meshEntities.current as entity (entity)}
92
- <Frame {entity}>
93
- <Label text={entity.get(traits.Name)} />
94
- </Frame>
89
+ <Frame {entity} />
95
90
  {/each}
96
91
 
97
92
  {#each points.current as entity (entity)}
98
- <Points {entity}>
99
- <Label text={entity.get(traits.Name)} />
100
- </Points>
93
+ <Points {entity} />
101
94
  {/each}
102
95
 
103
96
  {#each lines.current as entity (entity)}
104
- <Line {entity}>
105
- <Label text={entity.get(traits.Name)} />
106
- </Line>
97
+ <Line {entity} />
107
98
  {/each}
108
99
 
109
100
  {#each gltfs.current as entity (entity)}
110
- <GLTF {entity}>
111
- <Label text={entity.get(traits.Name)} />
112
- </GLTF>
101
+ <GLTF {entity} />
113
102
  {/each}
114
103
 
115
104
  <Arrows />
105
+
106
+ {#if enableLabels}
107
+ <Labels />
108
+ {/if}
@@ -1,18 +1,3 @@
1
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
- $$bindings?: Bindings;
4
- } & Exports;
5
- (internal: unknown, props: {
6
- $$events?: Events;
7
- $$slots?: Slots;
8
- }): Exports & {
9
- $set?: any;
10
- $on?: any;
11
- };
12
- z_$$bindings?: Bindings;
13
- }
14
- declare const Entities: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
- [evt: string]: CustomEvent<any>;
16
- }, {}, {}, string>;
17
- type Entities = InstanceType<typeof Entities>;
1
+ declare const Entities: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type Entities = ReturnType<typeof Entities>;
18
3
  export default Entities;
@@ -1,25 +1,91 @@
1
1
  <script lang="ts">
2
+ import type { Entity } from 'koota'
3
+
4
+ import { useThrelte } from '@threlte/core'
2
5
  import { HTML } from '@threlte/extras'
6
+ import { untrack } from 'svelte'
7
+ import { Group } from 'three'
8
+
9
+ import { traits, useTag, useTrait } from '../../ecs'
3
10
 
4
- import { useSettings } from '../../hooks/useSettings.svelte'
11
+ import { labels } from './labelLayout/labelStore.svelte'
5
12
 
6
13
  interface Props {
7
- text?: string
14
+ entity: Entity
8
15
  }
9
16
 
10
- let { text }: Props = $props()
17
+ let { entity }: Props = $props()
18
+
19
+ const { invalidate } = useThrelte()
20
+
21
+ const matrix = useTrait(() => entity, traits.WorldMatrix)
22
+ const name = useTrait(() => entity, traits.Name)
23
+ const color = useTrait(() => entity, traits.Color)
24
+ const selected = useTag(() => entity, traits.Selected)
25
+
26
+ let element = $state.raw<HTMLElement>()
27
+
28
+ $effect(() => {
29
+ const el = element
30
+
31
+ if (!el) return
32
+
33
+ return untrack(() => {
34
+ labels.add(el)
35
+ return () => labels.remove(el)
36
+ })
37
+ })
38
+
39
+ // Re-measure when the label text changes (its width drives slot geometry).
40
+ $effect(() => {
41
+ if (name.current) {
42
+ untrack(() => labels.touch())
43
+ }
44
+ })
11
45
 
12
- const settings = useSettings()
46
+ let ref = $state<Group>()
13
47
 
14
- const labels = $derived(settings.current.enableLabels)
48
+ $effect(() => {
49
+ if (matrix.current && ref) {
50
+ ref.matrix.copy(matrix.current)
51
+ ref.updateMatrixWorld()
52
+ invalidate()
53
+ }
54
+ })
15
55
  </script>
16
56
 
17
- {#if labels && text}
18
- <HTML
19
- center
20
- zIndexRange={[3, 0]}
21
- class="border-gray-7 border bg-white px-2 py-1 text-xs"
57
+ <HTML
58
+ center
59
+ zIndexRange={[3, 0]}
60
+ matrixAutoUpdate={false}
61
+ bind:ref
62
+ >
63
+ <div
64
+ class="label relative h-0 w-0"
65
+ bind:this={element}
22
66
  >
23
- {text}
24
- </HTML>
25
- {/if}
67
+ <svg class="link pointer-events-none absolute top-0 left-0 overflow-visible">
68
+ <line class="stroke-gray-9 stroke-1" />
69
+ </svg>
70
+ <div
71
+ class="dot border-gray-9 pointer-events-none absolute -top-1 -left-0 z-1 h-2 w-2 -translate-1/2 rounded-full border"
72
+ ></div>
73
+ <button
74
+ class={[
75
+ 'border-gray-9 text absolute z-2 border px-2 py-1 text-xs text-nowrap',
76
+ {
77
+ 'bg-gray-9 text-white': selected.current,
78
+ 'bg-white': !selected.current,
79
+ },
80
+ ]}
81
+ style={color.current
82
+ ? `border-color-left: rgb(${color.current.r}, ${color.current.g}, ${color.current.b})`
83
+ : undefined}
84
+ onclick={() => {
85
+ entity.add(traits.Selected)
86
+ }}
87
+ >
88
+ {name.current}
89
+ </button>
90
+ </div>
91
+ </HTML>
@@ -1,5 +1,6 @@
1
+ import type { Entity } from 'koota';
1
2
  interface Props {
2
- text?: string;
3
+ entity: Entity;
3
4
  }
4
5
  declare const Label: import("svelte").Component<Props, {}, "">;
5
6
  type Label = ReturnType<typeof Label>;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { useTask, useThrelte } from '@threlte/core'
3
+
4
+ import { traits, useQuery } from '../../ecs'
5
+
6
+ import Label from './Label.svelte'
7
+ import { createLabelLayout } from './labelLayout/createLabelLayout'
8
+ import { labels } from './labelLayout/labelStore.svelte'
9
+
10
+ const { camera, invalidate, size } = useThrelte()
11
+
12
+ const entities = useQuery(traits.Name)
13
+
14
+ const layout = createLabelLayout({ camera, size, invalidate, labels })
15
+
16
+ // Wake the on-demand render loop when labels are added/removed or their text
17
+ // changes, so the engine re-solves even while the camera is still. Reading
18
+ // `version` registers the reactive dependency.
19
+ $effect(() => {
20
+ if (labels.version >= 0) invalidate()
21
+ })
22
+
23
+ // `autoInvalidate: false` — the engine drives its own invalidation (camera
24
+ // motion, the version effect above, and while animating), so the task can run
25
+ // without pinning the on-demand Canvas to render every frame.
26
+ useTask(
27
+ (delta) => {
28
+ layout.frame(delta)
29
+ },
30
+ { autoInvalidate: false }
31
+ )
32
+ </script>
33
+
34
+ {#each entities.current as entity (entity)}
35
+ <Label {entity} />
36
+ {/each}
@@ -0,0 +1,3 @@
1
+ declare const Labels: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type Labels = ReturnType<typeof Labels>;
3
+ export default Labels;
@@ -41,9 +41,15 @@
41
41
 
42
42
  $effect(() => {
43
43
  if (!positions) return
44
+ // Track the IDs `addInstance` returns rather than assuming they're a
45
+ // sequential 0..N-1 range — when positions changes (e.g. a line gizmo
46
+ // being placed), cleanup-by-index would target slots that were never
47
+ // allocated for this effect run and throw "Invalid instanceId".
48
+ const instances: number[] = []
44
49
  for (let i = 0, l = positions.length; i < l; i += 3) {
45
50
  const dotIndex = i / 3
46
51
  const instance = mesh.addInstance(geometryID)
52
+ instances.push(instance)
47
53
  matrix.makeTranslation(positions[i + 0], positions[i + 1], positions[i + 2])
48
54
  matrix.scale(vec3.setScalar(scale))
49
55
  mesh.setMatrixAt(instance, matrix)
@@ -55,9 +61,8 @@
55
61
  }
56
62
 
57
63
  return () => {
58
- if (!positions) return
59
- for (let i = 0, l = positions.length / 3; i < l; i += 1) {
60
- mesh.deleteInstance(i)
64
+ for (const instance of instances) {
65
+ mesh.deleteInstance(instance)
61
66
  }
62
67
  }
63
68
  })
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Post-solve teleport handling: decides which nodes snap to their solved target
3
+ * this frame versus ease toward it. A big camera jump (large median anchor
4
+ * displacement) or a brand-new node (no prior anchor) places labels in their
5
+ * final spot rather than gliding across the screen; everything else eases.
6
+ * Always rolls each node's prevAx/prevAy forward for the next solve's comparison.
7
+ */
8
+ import type { LabelNode, SolverConfig } from './types';
9
+ export declare const applyTeleports: (nodes: LabelNode[], width: number, height: number, config: SolverConfig) => void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Post-solve teleport handling: decides which nodes snap to their solved target
3
+ * this frame versus ease toward it. A big camera jump (large median anchor
4
+ * displacement) or a brand-new node (no prior anchor) places labels in their
5
+ * final spot rather than gliding across the screen; everything else eases.
6
+ * Always rolls each node's prevAx/prevAy forward for the next solve's comparison.
7
+ */
8
+ /** The radius of a node's outermost slot (0 if it has none). */
9
+ const maxSlotRadius = (node) => {
10
+ const last = node.slots.at(-1);
11
+ return last ? last.radius : 0;
12
+ };
13
+ export const applyTeleports = (nodes, width, height, config) => {
14
+ const diag = Math.hypot(width, height);
15
+ const displacements = [];
16
+ for (const node of nodes) {
17
+ if (!Number.isNaN(node.prevAx)) {
18
+ displacements.push(Math.hypot(node.ax - node.prevAx, node.ay - node.prevAy));
19
+ }
20
+ }
21
+ let snapAll = displacements.length === 0;
22
+ if (!snapAll) {
23
+ displacements.sort((a, b) => a - b);
24
+ const median = displacements[displacements.length >> 1];
25
+ if (median > config.teleportFrac * diag)
26
+ snapAll = true;
27
+ }
28
+ for (const node of nodes) {
29
+ const ownJump = !Number.isNaN(node.prevAx) &&
30
+ Math.hypot(node.ax - node.prevAx, node.ay - node.prevAy) > maxSlotRadius(node) * 3;
31
+ if (snapAll || ownJump) {
32
+ node.cx = node.tx;
33
+ node.cy = node.ty;
34
+ node.settled = true;
35
+ }
36
+ node.prevAx = node.ax;
37
+ node.prevAy = node.ay;
38
+ }
39
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Builds each node's pruned, symmetric interaction set for one solve. The cell
3
+ * size is chosen so any two labels whose boxes could possibly interact share or
4
+ * border a cell, so the 3x3 query around each node finds every candidate.
5
+ */
6
+ import type { SpatialHash } from './spatialHash';
7
+ import type { LabelNode, SolverConfig } from './types';
8
+ export declare const buildNeighborhood: (grid: SpatialHash, nodes: LabelNode[], config: SolverConfig) => void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Builds each node's pruned, symmetric interaction set for one solve. The cell
3
+ * size is chosen so any two labels whose boxes could possibly interact share or
4
+ * border a cell, so the 3x3 query around each node finds every candidate.
5
+ */
6
+ export const buildNeighborhood = (grid, nodes, config) => {
7
+ const maxRingMult = Math.max(...config.ringRadiiCrowded);
8
+ // Cell size so any two labels whose boxes could interact share/border a cell.
9
+ let cell = 1;
10
+ for (const node of nodes) {
11
+ const halfDiag = Math.hypot(node.w / 2, node.h / 2);
12
+ const support = Math.max(node.w, node.h) / 2;
13
+ const outer = (support + node.dotR + config.dotPadding) * maxRingMult;
14
+ cell = Math.max(cell, 2 * (halfDiag + outer));
15
+ }
16
+ grid.build(nodes, cell);
17
+ // Symmetric neighbourhoods so the solver's incremental bookkeeping stays exact.
18
+ for (const node of nodes)
19
+ node.neighbors = grid.queryNeighbors(node, config.maxNeighbors);
20
+ for (const a of nodes) {
21
+ for (const b of a.neighbors) {
22
+ if (!b.neighbors.includes(a))
23
+ b.neighbors.push(a);
24
+ }
25
+ }
26
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A cheap, exact change-signal for the camera. Hashing the view + projection
3
+ * matrices (as raw float bits) plus the viewport size catches every pan, orbit,
4
+ * dolly, zoom, resize, and perspective/orthographic swap with no epsilon to tune.
5
+ * Computed every frame; the layout only re-solves when the hash changes.
6
+ */
7
+ import type { Camera } from 'three';
8
+ export declare function cameraMatrixHash(camera: Camera, width: number, height: number): number;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A cheap, exact change-signal for the camera. Hashing the view + projection
3
+ * matrices (as raw float bits) plus the viewport size catches every pan, orbit,
4
+ * dolly, zoom, resize, and perspective/orthographic swap with no epsilon to tune.
5
+ * Computed every frame; the layout only re-solves when the hash changes.
6
+ */
7
+ const f32 = new Float32Array(1);
8
+ const i32 = new Int32Array(f32.buffer);
9
+ function bits(value) {
10
+ f32[0] = value;
11
+ return i32[0];
12
+ }
13
+ export function cameraMatrixHash(camera, width, height) {
14
+ camera.updateMatrixWorld();
15
+ const view = camera.matrixWorldInverse.elements;
16
+ const proj = camera.projectionMatrix.elements;
17
+ let h = 2166136261 >>> 0;
18
+ for (let i = 0; i < 16; i++)
19
+ h = Math.imul(h ^ bits(view[i]), 16777619) >>> 0;
20
+ for (let i = 0; i < 16; i++)
21
+ h = Math.imul(h ^ bits(proj[i]), 16777619) >>> 0;
22
+ h = Math.imul(h ^ bits(width), 16777619) >>> 0;
23
+ h = Math.imul(h ^ bits(height), 16777619) >>> 0;
24
+ return h >>> 0;
25
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cost function for a candidate label placement. Lower is better.
3
+ *
4
+ * The hierarchy `lineBox >> boxDot > boxBox > lineLine >> stick > spread > len`
5
+ * is near-lexicographic: a single leader passing under another label always
6
+ * outranks fixing every overlap a node could have against its <=24 neighbors,
7
+ * so the optimizer eliminates crossings first (the user's top priority), then
8
+ * dot coverage, then box overlaps, then tidies the radial fan.
9
+ */
10
+ import type { LabelNode, SolverConfig } from './types';
11
+ export declare const W: {
12
+ /** DOMINANT — a leader passing under ANOTHER label's box. Requirement #1. */
13
+ lineBox: number;
14
+ /** Our box covering another node's dot. Worse than a box overlap. Requirement #4. */
15
+ boxDot: number;
16
+ /** Two label boxes overlapping. Requirement #2. */
17
+ boxBox: number;
18
+ /** Two leaders crossing — thin lines, mild. Supports the radial fan. */
19
+ lineLine: number;
20
+ /** Sticky bonus for staying on the previous slot (anti flip-flop). Requirement #6. */
21
+ stick: number;
22
+ /** Penalty when a slot's angle nearly coincides with a neighbor's leader angle. */
23
+ spread: number;
24
+ /** Outward-fan preference: cheaper to point away from the local cluster. Requirement #3. */
25
+ radial: number;
26
+ /** Leader length — keep labels close to their dot. Small. */
27
+ len: number;
28
+ };
29
+ /**
30
+ * The geometric "bad" terms only (leader-under-box, box-over-dot, box-box,
31
+ * leader-leader, angular spread) for placing `node` at slot `si` against its
32
+ * committed neighbors. This is the local-search objective AND its termination
33
+ * guard — exactly 0 when the node has no crossings/overlaps. The solver both
34
+ * selects and accepts moves on this value so it can never lock a node at a
35
+ * placement whose conflict another available slot would reduce.
36
+ */
37
+ export declare function evalConflict(node: LabelNode, si: number, neighbors: LabelNode[], config: SolverConfig): number;
38
+ /**
39
+ * Conflict-independent "tidiness" of a slot: short leaders, pointing away from
40
+ * the local cluster centroid (radial fan-out), with a bonus for staying put.
41
+ * Used only as a tie-break among slots of equal conflict, never to override a
42
+ * conflict reduction.
43
+ */
44
+ export declare function placementBias(node: LabelNode, si: number): number;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Cost function for a candidate label placement. Lower is better.
3
+ *
4
+ * The hierarchy `lineBox >> boxDot > boxBox > lineLine >> stick > spread > len`
5
+ * is near-lexicographic: a single leader passing under another label always
6
+ * outranks fixing every overlap a node could have against its <=24 neighbors,
7
+ * so the optimizer eliminates crossings first (the user's top priority), then
8
+ * dot coverage, then box overlaps, then tidies the radial fan.
9
+ */
10
+ import { overlapAreaFrac, rectCircleOverlap, rectsOverlap, segmentRectPenetration, segmentsCross, } from './geometry';
11
+ export const W = {
12
+ /** DOMINANT — a leader passing under ANOTHER label's box. Requirement #1. */
13
+ lineBox: 1000,
14
+ /** Our box covering another node's dot. Worse than a box overlap. Requirement #4. */
15
+ boxDot: 200,
16
+ /** Two label boxes overlapping. Requirement #2. */
17
+ boxBox: 120,
18
+ /** Two leaders crossing — thin lines, mild. Supports the radial fan. */
19
+ lineLine: 60,
20
+ /** Sticky bonus for staying on the previous slot (anti flip-flop). Requirement #6. */
21
+ stick: 35,
22
+ /** Penalty when a slot's angle nearly coincides with a neighbor's leader angle. */
23
+ spread: 15,
24
+ /** Outward-fan preference: cheaper to point away from the local cluster. Requirement #3. */
25
+ radial: 0.8,
26
+ /** Leader length — keep labels close to their dot. Small. */
27
+ len: 0.6,
28
+ };
29
+ /** Slot angles closer than this (radians, ~18deg) are penalised for an even fan. */
30
+ const SPREAD_ANGLE = 0.314;
31
+ // Reused scratch — the solver is single-threaded and never re-enters evalConflict.
32
+ const boxA = { cx: 0, cy: 0, hw: 0, hh: 0 };
33
+ const boxB = { cx: 0, cy: 0, hw: 0, hh: 0 };
34
+ const boxPad = { cx: 0, cy: 0, hw: 0, hh: 0 };
35
+ const segA = { x1: 0, y1: 0, x2: 0, y2: 0 };
36
+ const segB = { x1: 0, y1: 0, x2: 0, y2: 0 };
37
+ /**
38
+ * The geometric "bad" terms only (leader-under-box, box-over-dot, box-box,
39
+ * leader-leader, angular spread) for placing `node` at slot `si` against its
40
+ * committed neighbors. This is the local-search objective AND its termination
41
+ * guard — exactly 0 when the node has no crossings/overlaps. The solver both
42
+ * selects and accepts moves on this value so it can never lock a node at a
43
+ * placement whose conflict another available slot would reduce.
44
+ */
45
+ export function evalConflict(node, si, neighbors, config) {
46
+ const s = node.slots[si];
47
+ const cx = node.ax + s.dx;
48
+ const cy = node.ay + s.dy;
49
+ boxA.cx = cx;
50
+ boxA.cy = cy;
51
+ boxA.hw = node.w / 2;
52
+ boxA.hh = node.h / 2;
53
+ segA.x1 = node.ax;
54
+ segA.y1 = node.ay;
55
+ segA.x2 = cx;
56
+ segA.y2 = cy;
57
+ const halfPad = config.labelPadding / 2;
58
+ let cost = 0;
59
+ for (let t = 0; t < neighbors.length; t++) {
60
+ const m = neighbors[t];
61
+ const ms = m.slots[m.slotIndex];
62
+ const mx = m.ax + ms.dx;
63
+ const my = m.ay + ms.dy;
64
+ boxB.cx = mx;
65
+ boxB.cy = my;
66
+ boxB.hw = m.w / 2;
67
+ boxB.hh = m.h / 2;
68
+ segB.x1 = m.ax;
69
+ segB.y1 = m.ay;
70
+ segB.x2 = mx;
71
+ segB.y2 = my;
72
+ // Our leader under their box (expanded so a grazing line still reads as a crossing).
73
+ boxPad.cx = mx;
74
+ boxPad.cy = my;
75
+ boxPad.hw = m.w / 2 + halfPad;
76
+ boxPad.hh = m.h / 2 + halfPad;
77
+ const p1 = segmentRectPenetration(segA, boxPad);
78
+ if (p1 > 0)
79
+ cost += W.lineBox * p1;
80
+ // Their leader under our box.
81
+ boxPad.cx = cx;
82
+ boxPad.cy = cy;
83
+ boxPad.hw = node.w / 2 + halfPad;
84
+ boxPad.hh = node.h / 2 + halfPad;
85
+ const p2 = segmentRectPenetration(segB, boxPad);
86
+ if (p2 > 0)
87
+ cost += W.lineBox * p2;
88
+ // Our box covering their dot.
89
+ const dotClearance = m.dotR + config.dotPadding;
90
+ const d = rectCircleOverlap(boxA, m.ax, m.ay, dotClearance);
91
+ if (d > 0)
92
+ cost += W.boxDot * (d / dotClearance);
93
+ // Box-box overlap (binary dominates; area term provides a separation gradient).
94
+ if (rectsOverlap(boxA, boxB, config.labelPadding)) {
95
+ cost += W.boxBox + 2 * overlapAreaFrac(boxA, boxB);
96
+ }
97
+ // Leader-leader crossing.
98
+ if (segmentsCross(segA, segB))
99
+ cost += W.lineLine;
100
+ // Angular spread for an even fan.
101
+ let dd = Math.abs(s.angle - ms.angle);
102
+ if (dd > Math.PI)
103
+ dd = 2 * Math.PI - dd;
104
+ if (dd < SPREAD_ANGLE)
105
+ cost += W.spread;
106
+ }
107
+ return cost;
108
+ }
109
+ /**
110
+ * Conflict-independent "tidiness" of a slot: short leaders, pointing away from
111
+ * the local cluster centroid (radial fan-out), with a bonus for staying put.
112
+ * Used only as a tie-break among slots of equal conflict, never to override a
113
+ * conflict reduction.
114
+ */
115
+ export function placementBias(node, si) {
116
+ const s = node.slots[si];
117
+ let cost = W.len * s.radius;
118
+ const cax = node.centroidX - node.ax;
119
+ const cay = node.centroidY - node.ay;
120
+ const cl = Math.sqrt(cax * cax + cay * cay) || 1;
121
+ const align = (Math.cos(s.angle) * -cax + Math.sin(s.angle) * -cay) / cl;
122
+ cost += W.radial * (1 - align) * s.radius;
123
+ if (si === node.prevSlotIndex)
124
+ cost -= W.stick;
125
+ return cost;
126
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The label layout engine. Owns node lifecycle and the per-frame pipeline:
3
+ * a cheap dirty gate, then (only when dirty) measure → build neighbourhood →
4
+ * warm-start → solve → handle teleports, and every animating frame ease toward
5
+ * the solved targets and write them to the DOM. It idles (no work, no
6
+ * invalidate) once the camera is still and every label has settled.
7
+ */
8
+ import type { Camera } from 'three';
9
+ import type { LabelStore } from './labelStore.svelte';
10
+ import { type SolverConfig } from './types';
11
+ export interface LayoutDeps {
12
+ camera: {
13
+ current: Camera;
14
+ };
15
+ size: {
16
+ current: {
17
+ width: number;
18
+ height: number;
19
+ };
20
+ };
21
+ invalidate: () => void;
22
+ labels: LabelStore;
23
+ config?: Partial<SolverConfig>;
24
+ }
25
+ export declare function createLabelLayout(deps: LayoutDeps): {
26
+ frame: (delta: number) => void;
27
+ };