@viamrobotics/motion-tools 1.33.0 → 1.33.1
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/dist/components/Entities/Entities.svelte +18 -25
- package/dist/components/Entities/Entities.svelte.d.ts +2 -17
- package/dist/components/Entities/Label.svelte +79 -13
- package/dist/components/Entities/Label.svelte.d.ts +2 -1
- package/dist/components/Entities/Labels.svelte +36 -0
- package/dist/components/Entities/Labels.svelte.d.ts +3 -0
- package/dist/components/Entities/LineDots.svelte +8 -3
- package/dist/components/Entities/labelLayout/applyTeleports.d.ts +9 -0
- package/dist/components/Entities/labelLayout/applyTeleports.js +39 -0
- package/dist/components/Entities/labelLayout/buildNeighborhood.d.ts +8 -0
- package/dist/components/Entities/labelLayout/buildNeighborhood.js +26 -0
- package/dist/components/Entities/labelLayout/cameraHash.d.ts +8 -0
- package/dist/components/Entities/labelLayout/cameraHash.js +25 -0
- package/dist/components/Entities/labelLayout/cost.d.ts +44 -0
- package/dist/components/Entities/labelLayout/cost.js +126 -0
- package/dist/components/Entities/labelLayout/createLabelLayout.d.ts +27 -0
- package/dist/components/Entities/labelLayout/createLabelLayout.js +194 -0
- package/dist/components/Entities/labelLayout/geometry.d.ts +20 -0
- package/dist/components/Entities/labelLayout/geometry.js +151 -0
- package/dist/components/Entities/labelLayout/labelStore.svelte.d.ts +17 -0
- package/dist/components/Entities/labelLayout/labelStore.svelte.js +28 -0
- package/dist/components/Entities/labelLayout/measure.d.ts +13 -0
- package/dist/components/Entities/labelLayout/measure.js +42 -0
- package/dist/components/Entities/labelLayout/slots.d.ts +11 -0
- package/dist/components/Entities/labelLayout/slots.js +47 -0
- package/dist/components/Entities/labelLayout/solve.d.ts +11 -0
- package/dist/components/Entities/labelLayout/solve.js +93 -0
- package/dist/components/Entities/labelLayout/spatialHash.d.ts +15 -0
- package/dist/components/Entities/labelLayout/spatialHash.js +53 -0
- package/dist/components/Entities/labelLayout/types.d.ts +105 -0
- package/dist/components/Entities/labelLayout/types.js +19 -0
- package/dist/components/Entities/labelLayout/writeBack.d.ts +20 -0
- package/dist/components/Entities/labelLayout/writeBack.js +51 -0
- package/dist/components/Scene.svelte +2 -1
- package/dist/components/SelectedTransformControls.svelte +65 -47
- package/dist/components/overlay/Details.svelte +198 -224
- package/dist/components/overlay/Details.svelte.d.ts +1 -1
- package/dist/components/overlay/Popover.svelte +6 -4
- package/dist/components/overlay/Popover.svelte.d.ts +6 -2
- package/dist/components/overlay/dashboard/Button.svelte +7 -2
- package/dist/components/overlay/dashboard/Button.svelte.d.ts +2 -1
- package/dist/components/overlay/details/AxesHelperDetails.svelte +32 -0
- package/dist/components/overlay/details/AxesHelperDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/ColorDetails.svelte +35 -0
- package/dist/components/overlay/details/ColorDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/GeometryDetails.svelte +104 -0
- package/dist/components/overlay/details/GeometryDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/LineDetails/LineDetails.svelte +196 -0
- package/dist/components/overlay/details/LineDetails/LineDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/LineDetails/linePositions.d.ts +3 -0
- package/dist/components/overlay/details/LineDetails/linePositions.js +30 -0
- package/dist/components/overlay/details/OpacityDetails.svelte +44 -0
- package/dist/components/overlay/details/OpacityDetails.svelte.d.ts +7 -0
- package/dist/components/overlay/details/PoseDetails.svelte +189 -0
- package/dist/components/overlay/details/PoseDetails.svelte.d.ts +14 -0
- package/dist/ecs/traits.d.ts +1 -1
- package/dist/ecs/traits.js +1 -1
- package/dist/hooks/useWorldState.svelte.js +39 -50
- package/package.json +3 -1
|
@@ -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
|
|
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
|
-
|
|
2
|
-
|
|
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 {
|
|
11
|
+
import { labels } from './labelLayout/labelStore.svelte'
|
|
5
12
|
|
|
6
13
|
interface Props {
|
|
7
|
-
|
|
14
|
+
entity: Entity
|
|
8
15
|
}
|
|
9
16
|
|
|
10
|
-
let {
|
|
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
|
-
|
|
46
|
+
let ref = $state<Group>()
|
|
13
47
|
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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>
|
|
@@ -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}
|
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
+
};
|