@viamrobotics/motion-tools 1.33.1 → 1.34.0
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/overlay/Details.svelte +13 -3
- package/dist/hooks/usePartConfig.svelte.js +8 -6
- package/dist/hooks/useSettings.svelte.d.ts +1 -0
- package/dist/hooks/useSettings.svelte.js +1 -0
- package/dist/hooks/useStandaloneLLM.svelte.d.ts +7 -0
- package/dist/hooks/useStandaloneLLM.svelte.js +32 -0
- package/dist/hooks/useWorldState.svelte.js +55 -19
- package/dist/plugins/LLMSceneBuilder/AISettings.svelte +20 -0
- package/dist/plugins/LLMSceneBuilder/AISettings.svelte.d.ts +18 -0
- package/dist/plugins/LLMSceneBuilder/LLMSceneBuilder.svelte +14 -0
- package/dist/plugins/LLMSceneBuilder/LLMSceneBuilder.svelte.d.ts +7 -0
- package/dist/plugins/LLMSceneBuilder/SceneBuilder.svelte +149 -0
- package/dist/plugins/LLMSceneBuilder/SceneBuilder.svelte.d.ts +3 -0
- package/dist/plugins/LLMSceneBuilder/frameDeltaAdapter.d.ts +40 -0
- package/dist/plugins/LLMSceneBuilder/frameDeltaAdapter.js +65 -0
- package/dist/plugins/LLMSceneBuilder/useSceneBuilder.svelte.d.ts +46 -0
- package/dist/plugins/LLMSceneBuilder/useSceneBuilder.svelte.js +134 -0
- package/dist/plugins/index.d.ts +4 -0
- package/dist/plugins/index.js +3 -0
- package/dist/transform.js +21 -0
- package/package.json +7 -3
|
@@ -127,9 +127,19 @@
|
|
|
127
127
|
let geometryTabIndex = $derived(geometryTypes.indexOf(geometryType))
|
|
128
128
|
|
|
129
129
|
$effect(() => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
const nextType = geometryTypes[geometryTabIndex]
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* geometryTabIndex is derived from the entity's geometry traits, so on
|
|
134
|
+
* selection (or any trait-driven recompute) nextType already equals
|
|
135
|
+
* geometryType — firing then would call updateFrame, dirtying the part
|
|
136
|
+
* config and resetting the geometry to default dimensions. Only a user
|
|
137
|
+
* tab pick sets geometryTabIndex ahead of the trait, so guard on the two
|
|
138
|
+
* differing to fire solely for user-initiated changes.
|
|
139
|
+
*/
|
|
140
|
+
if (nextType === geometryType) return
|
|
141
|
+
|
|
142
|
+
detailConfigUpdater.setGeometryType(entity, nextType)
|
|
133
143
|
})
|
|
134
144
|
|
|
135
145
|
let copied = $state(false)
|
|
@@ -105,12 +105,14 @@ export const providePartConfig = (partID, params) => {
|
|
|
105
105
|
y: pose.y ?? currentPose.y,
|
|
106
106
|
z: pose.z ?? currentPose.z,
|
|
107
107
|
};
|
|
108
|
-
component.frame.orientation
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
component.frame.orientation = {
|
|
109
|
+
type: 'ov_degrees',
|
|
110
|
+
value: {
|
|
111
|
+
x: pose.oX ?? currentPose.oX,
|
|
112
|
+
y: pose.oY ?? currentPose.oY,
|
|
113
|
+
z: pose.oZ ?? currentPose.oZ,
|
|
114
|
+
th: pose.theta ?? currentPose.theta,
|
|
115
|
+
},
|
|
114
116
|
};
|
|
115
117
|
if (geometry) {
|
|
116
118
|
if (geometry.type === 'none') {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type InferCallback } from '../plugins';
|
|
2
|
+
interface StandaloneLLMContext {
|
|
3
|
+
current: InferCallback;
|
|
4
|
+
}
|
|
5
|
+
export declare const provideStandaloneLLM: () => StandaloneLLMContext;
|
|
6
|
+
export declare const useStandaloneLLMContext: () => StandaloneLLMContext;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { backendIP, websocketPort } from '../defines';
|
|
3
|
+
import { useSettings } from './useSettings.svelte';
|
|
4
|
+
import {} from '../plugins';
|
|
5
|
+
const key = Symbol('standalone-llm-context');
|
|
6
|
+
export const provideStandaloneLLM = () => {
|
|
7
|
+
const settings = useSettings();
|
|
8
|
+
const standaloneInfer = async (prompt, components) => {
|
|
9
|
+
const res = await fetch(`http://${backendIP}:${websocketPort}/scene-builder`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({
|
|
13
|
+
prompt,
|
|
14
|
+
components,
|
|
15
|
+
anthropicKey: settings.current.anthropicKey || undefined,
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
throw new Error(`${res.status}: ${await res.text()}`);
|
|
20
|
+
}
|
|
21
|
+
return res.json();
|
|
22
|
+
};
|
|
23
|
+
const context = setContext(key, {
|
|
24
|
+
get current() {
|
|
25
|
+
return standaloneInfer;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
return context;
|
|
29
|
+
};
|
|
30
|
+
export const useStandaloneLLMContext = () => {
|
|
31
|
+
return getContext(key);
|
|
32
|
+
};
|
|
@@ -5,7 +5,7 @@ import { Matrix4 } from 'three';
|
|
|
5
5
|
import { asFloat32Array, inMeters } from '../buffer';
|
|
6
6
|
import { createChunkLoader } from '../chunking';
|
|
7
7
|
import { drawTransform, updateMetadata } from '../draw';
|
|
8
|
-
import { traits, useWorld } from '../ecs';
|
|
8
|
+
import { hierarchy, traits, useWorld } from '../ecs';
|
|
9
9
|
import { isPointCloud } from '../geometry';
|
|
10
10
|
import { metadataFromStruct } from '../metadata';
|
|
11
11
|
import { createPose, poseToMatrix } from '../transform';
|
|
@@ -27,6 +27,10 @@ export const provideWorldStates = () => {
|
|
|
27
27
|
};
|
|
28
28
|
});
|
|
29
29
|
};
|
|
30
|
+
// FieldMask paths are proto field names; spec-compliant backends emit
|
|
31
|
+
// snake_case (`pose_in_observer_frame`) while some emit camelCase. Normalize
|
|
32
|
+
// to camelCase so matching against the message's accessors is casing-agnostic.
|
|
33
|
+
const snakeToCamel = (path) => path.replaceAll(/_([a-z])/g, (_, char) => char.toUpperCase());
|
|
30
34
|
const decodeBase64 = (encoded) => {
|
|
31
35
|
const binary = atob(encoded);
|
|
32
36
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -87,6 +91,11 @@ const createWorldState = (client) => {
|
|
|
87
91
|
const world = useWorld();
|
|
88
92
|
const relationships = useRelationships();
|
|
89
93
|
const entities = new Map();
|
|
94
|
+
// UUIDs the stream has removed; guards against a stale initial snapshot or a
|
|
95
|
+
// self-heal fetch re-creating an entity the server has already deleted.
|
|
96
|
+
const removedUUIDs = new Set();
|
|
97
|
+
// UUIDs with an in-flight self-heal `getTransform`, to dedupe concurrent fetches.
|
|
98
|
+
const pendingSpawns = new Set();
|
|
90
99
|
const chunkLoader = createChunkLoader({
|
|
91
100
|
world,
|
|
92
101
|
invalidate,
|
|
@@ -105,7 +114,7 @@ const createWorldState = (client) => {
|
|
|
105
114
|
},
|
|
106
115
|
});
|
|
107
116
|
const spawnEntity = (transform) => {
|
|
108
|
-
if (entities.has(transform.uuidString)) {
|
|
117
|
+
if (entities.has(transform.uuidString) || removedUUIDs.has(transform.uuidString)) {
|
|
109
118
|
return;
|
|
110
119
|
}
|
|
111
120
|
const spawned = drawTransform(world, transform, traits.WorldStateStoreAPI, { removable: false });
|
|
@@ -118,6 +127,7 @@ const createWorldState = (client) => {
|
|
|
118
127
|
invalidate();
|
|
119
128
|
};
|
|
120
129
|
const destroyEntity = (uuid) => {
|
|
130
|
+
removedUUIDs.add(uuid);
|
|
121
131
|
const entity = entities.get(uuid);
|
|
122
132
|
if (!entity)
|
|
123
133
|
return;
|
|
@@ -126,29 +136,54 @@ const createWorldState = (client) => {
|
|
|
126
136
|
}
|
|
127
137
|
entities.delete(uuid);
|
|
128
138
|
};
|
|
139
|
+
// Spawn an entity whose UPDATE delta arrived before the initial snapshot
|
|
140
|
+
// created it. The delta carries only changed fields, so fetch the full
|
|
141
|
+
// transform; skip if it was removed or already spawned meanwhile.
|
|
142
|
+
const spawnFromServer = async (uuid) => {
|
|
143
|
+
if (entities.has(uuid) || removedUUIDs.has(uuid) || pendingSpawns.has(uuid))
|
|
144
|
+
return;
|
|
145
|
+
pendingSpawns.add(uuid);
|
|
146
|
+
try {
|
|
147
|
+
const transform = await client.current?.getTransform(uuid);
|
|
148
|
+
if (transform && !removedUUIDs.has(uuid)) {
|
|
149
|
+
spawnEntity(transform);
|
|
150
|
+
invalidate();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
console.error('World state self-heal failed for', uuid, error);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
pendingSpawns.delete(uuid);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
129
160
|
const updateEntity = (transform, changes) => {
|
|
130
161
|
const entity = entities.get(transform.uuidString);
|
|
131
|
-
if (!entity)
|
|
162
|
+
if (!entity) {
|
|
163
|
+
void spawnFromServer(transform.uuidString);
|
|
132
164
|
return;
|
|
165
|
+
}
|
|
133
166
|
let metadataDirty = false;
|
|
134
|
-
for (const
|
|
135
|
-
if (typeof
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
entity.add(traits.Matrix(poseToMatrix(createPose(transform.poseInObserverFrame?.pose), new Matrix4())));
|
|
144
|
-
}
|
|
167
|
+
for (const rawPath of changes) {
|
|
168
|
+
if (typeof rawPath !== 'string')
|
|
169
|
+
continue;
|
|
170
|
+
const path = snakeToCamel(rawPath);
|
|
171
|
+
if (path.startsWith('poseInObserverFrame')) {
|
|
172
|
+
const matrix = entity.get(traits.Matrix);
|
|
173
|
+
if (matrix) {
|
|
174
|
+
poseToMatrix(createPose(transform.poseInObserverFrame?.pose), matrix);
|
|
175
|
+
entity.changed(traits.Matrix);
|
|
145
176
|
}
|
|
146
|
-
else
|
|
147
|
-
traits.
|
|
148
|
-
}
|
|
149
|
-
else if (path.startsWith('metadata')) {
|
|
150
|
-
metadataDirty = true;
|
|
177
|
+
else {
|
|
178
|
+
entity.add(traits.Matrix(poseToMatrix(createPose(transform.poseInObserverFrame?.pose), new Matrix4())));
|
|
151
179
|
}
|
|
180
|
+
hierarchy.setParent(entity, transform.poseInObserverFrame?.referenceFrame);
|
|
181
|
+
}
|
|
182
|
+
else if (path.startsWith('physicalObject') && transform.physicalObject) {
|
|
183
|
+
traits.updateGeometryTrait(entity, transform.physicalObject);
|
|
184
|
+
}
|
|
185
|
+
else if (path.startsWith('metadata')) {
|
|
186
|
+
metadataDirty = true;
|
|
152
187
|
}
|
|
153
188
|
}
|
|
154
189
|
if (metadataDirty) {
|
|
@@ -170,6 +205,7 @@ const createWorldState = (client) => {
|
|
|
170
205
|
const applyEvents = (events) => {
|
|
171
206
|
for (const event of events) {
|
|
172
207
|
if (event.changeType === TransformChangeType.ADDED) {
|
|
208
|
+
removedUUIDs.delete(event.transform.uuidString);
|
|
173
209
|
spawnEntity(event.transform);
|
|
174
210
|
}
|
|
175
211
|
else if (event.changeType === TransformChangeType.REMOVED) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Input } from '@viamrobotics/prime-core'
|
|
3
|
+
|
|
4
|
+
import { useSettings } from '../../hooks/useSettings.svelte'
|
|
5
|
+
|
|
6
|
+
const settings = useSettings()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="flex flex-col gap-2.5 text-xs">
|
|
10
|
+
<h3 class="border-gray-3 border-b py-1 text-sm"><strong>Anthropic</strong></h3>
|
|
11
|
+
<label class="flex flex-col gap-1">
|
|
12
|
+
API key
|
|
13
|
+
<Input
|
|
14
|
+
type="password"
|
|
15
|
+
bind:value={settings.current.anthropicKey}
|
|
16
|
+
placeholder="sk-ant-..."
|
|
17
|
+
/>
|
|
18
|
+
</label>
|
|
19
|
+
<p class="text-gray-5">Used by the Scene Builder AI feature. Stored locally in your browser.</p>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
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 AISettings: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type AISettings = InstanceType<typeof AISettings>;
|
|
18
|
+
export default AISettings;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import SceneBuilder from './SceneBuilder.svelte'
|
|
3
|
+
import { type InferCallback, provideSceneBuilder } from './useSceneBuilder.svelte'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
onInfer: InferCallback
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { onInfer }: Props = $props()
|
|
10
|
+
|
|
11
|
+
provideSceneBuilder((prompt, components) => onInfer(prompt, components))
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<SceneBuilder />
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type InferCallback } from './useSceneBuilder.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
onInfer: InferCallback;
|
|
4
|
+
}
|
|
5
|
+
declare const LLMSceneBuilder: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type LLMSceneBuilder = ReturnType<typeof LLMSceneBuilder>;
|
|
7
|
+
export default LLMSceneBuilder;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Portal } from '@threlte/extras'
|
|
3
|
+
|
|
4
|
+
import DashboardButton from '../../components/overlay/dashboard/Button.svelte'
|
|
5
|
+
import FloatingPanel from '../../components/overlay/FloatingPanel.svelte'
|
|
6
|
+
|
|
7
|
+
import { useSceneBuilder } from './useSceneBuilder.svelte'
|
|
8
|
+
|
|
9
|
+
const sceneBuilder = useSceneBuilder()
|
|
10
|
+
|
|
11
|
+
let isOpen = $state(false)
|
|
12
|
+
let prompt = $state('')
|
|
13
|
+
|
|
14
|
+
const canSubmit = $derived(prompt.trim().length > 0 && sceneBuilder.uiState === 'idle')
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<Portal id="dashboard">
|
|
18
|
+
<fieldset>
|
|
19
|
+
<DashboardButton
|
|
20
|
+
active={isOpen}
|
|
21
|
+
icon="robot-outline"
|
|
22
|
+
description="Frame Builder"
|
|
23
|
+
onclick={() => (isOpen = !isOpen)}
|
|
24
|
+
/>
|
|
25
|
+
</fieldset>
|
|
26
|
+
</Portal>
|
|
27
|
+
|
|
28
|
+
<Portal id="dom">
|
|
29
|
+
<FloatingPanel
|
|
30
|
+
bind:isOpen
|
|
31
|
+
title="Frame Builder"
|
|
32
|
+
defaultSize={{ width: 480, height: 420 }}
|
|
33
|
+
resizable
|
|
34
|
+
>
|
|
35
|
+
<div class="flex h-full flex-col gap-3 p-3 text-xs">
|
|
36
|
+
<!-- prompt input -->
|
|
37
|
+
<div class="flex gap-2">
|
|
38
|
+
<textarea
|
|
39
|
+
class="flex-1 resize-none rounded border border-gray-300 p-2 text-xs focus:ring-1 focus:ring-gray-400 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
|
40
|
+
placeholder="Describe the frame change, e.g. 'Move the arm 200mm forward along X'"
|
|
41
|
+
rows={3}
|
|
42
|
+
disabled={sceneBuilder.uiState === 'loading' || sceneBuilder.uiState === 'diff'}
|
|
43
|
+
bind:value={prompt}
|
|
44
|
+
onkeydown={(e) => {
|
|
45
|
+
e.stopImmediatePropagation()
|
|
46
|
+
if (e.key === 'Enter' && !e.shiftKey && canSubmit) {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
sceneBuilder.submit(prompt)
|
|
49
|
+
prompt = ''
|
|
50
|
+
}
|
|
51
|
+
}}
|
|
52
|
+
></textarea>
|
|
53
|
+
<button
|
|
54
|
+
class="self-end rounded bg-gray-800 px-3 py-1.5 text-xs text-white hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
55
|
+
disabled={!canSubmit}
|
|
56
|
+
onclick={() => {
|
|
57
|
+
sceneBuilder.submit(prompt)
|
|
58
|
+
prompt = ''
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
Submit
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- loading -->
|
|
66
|
+
{#if sceneBuilder.uiState === 'loading'}
|
|
67
|
+
<p class="text-gray-5 text-center">Thinking…</p>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
<!-- diff ready -->
|
|
71
|
+
{#if sceneBuilder.uiState === 'diff'}
|
|
72
|
+
{#if sceneBuilder.explanation}
|
|
73
|
+
<p class="text-gray-6 italic">{sceneBuilder.explanation}</p>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{#if sceneBuilder.diffGroups.length > 0}
|
|
77
|
+
<div class="flex flex-col gap-2 overflow-auto">
|
|
78
|
+
{#each sceneBuilder.diffGroups as group (group.componentName)}
|
|
79
|
+
<div class="rounded border border-gray-200">
|
|
80
|
+
<div
|
|
81
|
+
class="flex items-baseline gap-2 border-b border-gray-200 bg-gray-50 px-2 py-1"
|
|
82
|
+
>
|
|
83
|
+
<span class="font-mono font-medium">{group.componentName}</span>
|
|
84
|
+
{#if group.explanation}
|
|
85
|
+
<span class="text-gray-5 truncate italic">{group.explanation}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
<table class="w-full text-left">
|
|
89
|
+
<thead class="sr-only">
|
|
90
|
+
<tr>
|
|
91
|
+
<th>Field</th>
|
|
92
|
+
<th>Before</th>
|
|
93
|
+
<th>After</th>
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
{#each group.changes as change (change.field)}
|
|
98
|
+
<tr class="border-t border-gray-100 first:border-t-0">
|
|
99
|
+
<td class="px-2 py-1 font-mono">{change.field}</td>
|
|
100
|
+
<td class="text-red-6 px-2 py-1 font-mono">{change.oldValue}</td>
|
|
101
|
+
<td class="text-green-6 px-2 py-1 font-mono">{change.newValue}</td>
|
|
102
|
+
</tr>
|
|
103
|
+
{/each}
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
</div>
|
|
107
|
+
{/each}
|
|
108
|
+
</div>
|
|
109
|
+
{:else}
|
|
110
|
+
<p class="text-gray-5 text-center">No frame changes proposed.</p>
|
|
111
|
+
{/if}
|
|
112
|
+
|
|
113
|
+
{#if sceneBuilder.updateErrors.length > 0}
|
|
114
|
+
<ul class="text-red-6 space-y-0.5">
|
|
115
|
+
{#each sceneBuilder.updateErrors as err (err.componentName)}
|
|
116
|
+
<li><span class="font-mono">{err.componentName}</span>: {err.reason}</li>
|
|
117
|
+
{/each}
|
|
118
|
+
</ul>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
<div class="mt-auto flex gap-2">
|
|
122
|
+
<button
|
|
123
|
+
class="rounded bg-gray-800 px-3 py-1.5 text-white hover:bg-gray-700"
|
|
124
|
+
onclick={sceneBuilder.confirm}
|
|
125
|
+
>
|
|
126
|
+
Confirm
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
class="rounded border border-gray-300 px-3 py-1.5 hover:bg-gray-50"
|
|
130
|
+
onclick={sceneBuilder.cancel}
|
|
131
|
+
>
|
|
132
|
+
Cancel
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
{/if}
|
|
136
|
+
|
|
137
|
+
<!-- error -->
|
|
138
|
+
{#if sceneBuilder.uiState === 'error'}
|
|
139
|
+
<p class="text-red-6">{sceneBuilder.errorMessage}</p>
|
|
140
|
+
<button
|
|
141
|
+
class="self-start rounded border border-gray-300 px-3 py-1.5 hover:bg-gray-50"
|
|
142
|
+
onclick={sceneBuilder.resetError}
|
|
143
|
+
>
|
|
144
|
+
Try again
|
|
145
|
+
</button>
|
|
146
|
+
{/if}
|
|
147
|
+
</div>
|
|
148
|
+
</FloatingPanel>
|
|
149
|
+
</Portal>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Pose } from '@viamrobotics/sdk';
|
|
2
|
+
import type { Frame } from '../../frame';
|
|
3
|
+
import type { PartConfig } from '../../hooks/usePartConfig.svelte';
|
|
4
|
+
export interface FrameDelta {
|
|
5
|
+
componentName: string;
|
|
6
|
+
translation?: {
|
|
7
|
+
x?: number;
|
|
8
|
+
y?: number;
|
|
9
|
+
z?: number;
|
|
10
|
+
};
|
|
11
|
+
orientation?: {
|
|
12
|
+
roll?: number;
|
|
13
|
+
pitch?: number;
|
|
14
|
+
yaw?: number;
|
|
15
|
+
};
|
|
16
|
+
parent?: string;
|
|
17
|
+
explanation?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PreparedUpdate {
|
|
20
|
+
componentName: string;
|
|
21
|
+
parent: string;
|
|
22
|
+
previousParent: string;
|
|
23
|
+
pose: Pose;
|
|
24
|
+
previousPose: Pose;
|
|
25
|
+
geometry?: Frame['geometry'];
|
|
26
|
+
explanation?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface UpdateError {
|
|
29
|
+
componentName: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Validates LLM-proposed frame deltas and computes the resulting changes without
|
|
34
|
+
* applying them. Each PreparedUpdate carries old and new values so the caller
|
|
35
|
+
* can render a diff and confirm via useSceneBuilder's confirm().
|
|
36
|
+
*/
|
|
37
|
+
export declare function validateProposedFrameDeltas(deltas: FrameDelta[], config: PartConfig): {
|
|
38
|
+
errors: UpdateError[];
|
|
39
|
+
prepared: PreparedUpdate[];
|
|
40
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { applyEulerDeltaToPose, createPoseFromFrame } from '../../transform';
|
|
2
|
+
/**
|
|
3
|
+
* Validates LLM-proposed frame deltas and computes the resulting changes without
|
|
4
|
+
* applying them. Each PreparedUpdate carries old and new values so the caller
|
|
5
|
+
* can render a diff and confirm via useSceneBuilder's confirm().
|
|
6
|
+
*/
|
|
7
|
+
export function validateProposedFrameDeltas(deltas, config) {
|
|
8
|
+
const errors = [];
|
|
9
|
+
const prepared = [];
|
|
10
|
+
const knownNames = new Set(config.components.map((c) => c.name));
|
|
11
|
+
for (const delta of deltas) {
|
|
12
|
+
const component = config.components.find((c) => c.name === delta.componentName);
|
|
13
|
+
if (!component) {
|
|
14
|
+
errors.push({ componentName: delta.componentName, reason: 'Component not found in config' });
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!component.frame) {
|
|
18
|
+
errors.push({ componentName: delta.componentName, reason: 'Component has no frame' });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (delta.parent !== undefined &&
|
|
22
|
+
delta.parent !== 'world' &&
|
|
23
|
+
(!knownNames.has(delta.parent) || delta.parent === delta.componentName)) {
|
|
24
|
+
errors.push({
|
|
25
|
+
componentName: delta.componentName,
|
|
26
|
+
reason: delta.parent === delta.componentName
|
|
27
|
+
? `Component cannot be its own parent`
|
|
28
|
+
: `Parent '${delta.parent}' not found in config`,
|
|
29
|
+
});
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const previousPose = createPoseFromFrame(component.frame);
|
|
33
|
+
const previousParent = component.frame.parent;
|
|
34
|
+
const newParent = delta.parent ?? previousParent;
|
|
35
|
+
const newPose = {
|
|
36
|
+
x: delta.translation?.x ?? previousPose.x,
|
|
37
|
+
y: delta.translation?.y ?? previousPose.y,
|
|
38
|
+
z: delta.translation?.z ?? previousPose.z,
|
|
39
|
+
oX: previousPose.oX,
|
|
40
|
+
oY: previousPose.oY,
|
|
41
|
+
oZ: previousPose.oZ,
|
|
42
|
+
theta: previousPose.theta,
|
|
43
|
+
};
|
|
44
|
+
if (delta.orientation) {
|
|
45
|
+
applyEulerDeltaToPose(previousPose, delta.orientation, newPose);
|
|
46
|
+
}
|
|
47
|
+
if ([newPose.x, newPose.y, newPose.z, newPose.oX, newPose.oY, newPose.oZ, newPose.theta].some((v) => !Number.isFinite(v))) {
|
|
48
|
+
errors.push({
|
|
49
|
+
componentName: delta.componentName,
|
|
50
|
+
reason: 'Proposed values contain non-finite numbers',
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
prepared.push({
|
|
55
|
+
componentName: delta.componentName,
|
|
56
|
+
parent: newParent,
|
|
57
|
+
previousParent,
|
|
58
|
+
pose: newPose,
|
|
59
|
+
previousPose,
|
|
60
|
+
geometry: component.frame.geometry,
|
|
61
|
+
explanation: delta.explanation,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { errors, prepared };
|
|
65
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type FrameDelta, type UpdateError } from './frameDeltaAdapter';
|
|
2
|
+
type UIState = 'idle' | 'loading' | 'diff' | 'error';
|
|
3
|
+
interface FieldChange {
|
|
4
|
+
field: string;
|
|
5
|
+
oldValue: string;
|
|
6
|
+
newValue: string;
|
|
7
|
+
}
|
|
8
|
+
interface DiffGroup {
|
|
9
|
+
componentName: string;
|
|
10
|
+
explanation?: string;
|
|
11
|
+
changes: FieldChange[];
|
|
12
|
+
}
|
|
13
|
+
interface SceneBuilderContext {
|
|
14
|
+
readonly uiState: UIState;
|
|
15
|
+
readonly updateErrors: UpdateError[];
|
|
16
|
+
readonly explanation: string;
|
|
17
|
+
readonly errorMessage: string;
|
|
18
|
+
readonly diffGroups: DiffGroup[];
|
|
19
|
+
submit(prompt: string): Promise<void>;
|
|
20
|
+
confirm(): void;
|
|
21
|
+
cancel(): void;
|
|
22
|
+
resetError(): void;
|
|
23
|
+
}
|
|
24
|
+
export interface ComponentFrameInfo {
|
|
25
|
+
name: string;
|
|
26
|
+
frame: {
|
|
27
|
+
parent: string | undefined;
|
|
28
|
+
translation: {
|
|
29
|
+
x?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
z?: number;
|
|
32
|
+
} | undefined;
|
|
33
|
+
orientation: {
|
|
34
|
+
roll: number;
|
|
35
|
+
pitch: number;
|
|
36
|
+
yaw: number;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export type InferCallback = (prompt: string, components: ComponentFrameInfo[]) => Promise<{
|
|
41
|
+
updates: FrameDelta[];
|
|
42
|
+
explanation: string;
|
|
43
|
+
}>;
|
|
44
|
+
export declare const provideSceneBuilder: (onInfer: InferCallback) => void;
|
|
45
|
+
export declare const useSceneBuilder: () => SceneBuilderContext;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { usePartConfig } from '../../hooks/usePartConfig.svelte';
|
|
3
|
+
import { createPoseFromFrame, poseToEulerDegrees } from '../../transform';
|
|
4
|
+
import { validateProposedFrameDeltas } from './frameDeltaAdapter';
|
|
5
|
+
const key = Symbol('scene-builder-context');
|
|
6
|
+
export const provideSceneBuilder = (onInfer) => {
|
|
7
|
+
const partConfig = usePartConfig();
|
|
8
|
+
let uiState = $state('idle');
|
|
9
|
+
let deltas = $state([]);
|
|
10
|
+
let explanation = $state('');
|
|
11
|
+
let errorMessage = $state('');
|
|
12
|
+
// Re-derived whenever deltas or the current config changes (e.g. from a drag).
|
|
13
|
+
// confirm() therefore always applies the LLM's intent against the latest config —
|
|
14
|
+
// drag changes to unspecified axes are preserved, not overwritten.
|
|
15
|
+
const validation = $derived.by(() => deltas.length > 0
|
|
16
|
+
? validateProposedFrameDeltas(deltas, partConfig.current)
|
|
17
|
+
: { prepared: [], errors: [] });
|
|
18
|
+
const updateErrors = $derived(validation.errors);
|
|
19
|
+
const diffGroups = $derived(validation.prepared.flatMap((u) => {
|
|
20
|
+
const changes = [];
|
|
21
|
+
const roundMm = (v) => `${Math.round(v * 100) / 100}mm`;
|
|
22
|
+
const roundDeg = (v) => `${Math.round(v * 100) / 100}°`;
|
|
23
|
+
if (u.parent !== u.previousParent) {
|
|
24
|
+
changes.push({ field: 'parent', oldValue: u.previousParent, newValue: u.parent });
|
|
25
|
+
}
|
|
26
|
+
if (u.pose.x !== u.previousPose.x) {
|
|
27
|
+
changes.push({
|
|
28
|
+
field: 'translation.x',
|
|
29
|
+
oldValue: roundMm(u.previousPose.x),
|
|
30
|
+
newValue: roundMm(u.pose.x),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (u.pose.y !== u.previousPose.y) {
|
|
34
|
+
changes.push({
|
|
35
|
+
field: 'translation.y',
|
|
36
|
+
oldValue: roundMm(u.previousPose.y),
|
|
37
|
+
newValue: roundMm(u.pose.y),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (u.pose.z !== u.previousPose.z) {
|
|
41
|
+
changes.push({
|
|
42
|
+
field: 'translation.z',
|
|
43
|
+
oldValue: roundMm(u.previousPose.z),
|
|
44
|
+
newValue: roundMm(u.pose.z),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (u.pose.oX !== u.previousPose.oX ||
|
|
48
|
+
u.pose.oY !== u.previousPose.oY ||
|
|
49
|
+
u.pose.oZ !== u.previousPose.oZ ||
|
|
50
|
+
u.pose.theta !== u.previousPose.theta) {
|
|
51
|
+
const prev = poseToEulerDegrees(u.previousPose);
|
|
52
|
+
const next = poseToEulerDegrees(u.pose);
|
|
53
|
+
for (const axis of ['yaw', 'pitch', 'roll']) {
|
|
54
|
+
if (Math.abs(next[axis] - prev[axis]) > 0.01) {
|
|
55
|
+
changes.push({
|
|
56
|
+
field: axis,
|
|
57
|
+
oldValue: roundDeg(prev[axis]),
|
|
58
|
+
newValue: roundDeg(next[axis]),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return changes.length > 0
|
|
64
|
+
? [{ componentName: u.componentName, explanation: u.explanation, changes }]
|
|
65
|
+
: [];
|
|
66
|
+
}));
|
|
67
|
+
const clear = () => {
|
|
68
|
+
deltas = [];
|
|
69
|
+
explanation = '';
|
|
70
|
+
errorMessage = '';
|
|
71
|
+
};
|
|
72
|
+
setContext(key, {
|
|
73
|
+
get uiState() {
|
|
74
|
+
return uiState;
|
|
75
|
+
},
|
|
76
|
+
get updateErrors() {
|
|
77
|
+
return updateErrors;
|
|
78
|
+
},
|
|
79
|
+
get explanation() {
|
|
80
|
+
return explanation;
|
|
81
|
+
},
|
|
82
|
+
get errorMessage() {
|
|
83
|
+
return errorMessage;
|
|
84
|
+
},
|
|
85
|
+
get diffGroups() {
|
|
86
|
+
return diffGroups;
|
|
87
|
+
},
|
|
88
|
+
async submit(prompt) {
|
|
89
|
+
uiState = 'loading';
|
|
90
|
+
const components = partConfig.current.components
|
|
91
|
+
.filter((c) => c.frame !== undefined)
|
|
92
|
+
.map(({ name, frame }) => {
|
|
93
|
+
const pose = createPoseFromFrame(frame);
|
|
94
|
+
const { roll, pitch, yaw } = poseToEulerDegrees(pose);
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
frame: {
|
|
98
|
+
parent: frame.parent,
|
|
99
|
+
translation: frame.translation,
|
|
100
|
+
orientation: { roll, pitch, yaw },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const data = await onInfer(prompt.trim(), components);
|
|
106
|
+
deltas = data.updates;
|
|
107
|
+
explanation = data.explanation;
|
|
108
|
+
uiState = 'diff';
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
112
|
+
uiState = 'error';
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
confirm() {
|
|
116
|
+
for (const update of validation.prepared) {
|
|
117
|
+
partConfig.updateFrame(update.componentName, update.parent, update.pose, update.geometry);
|
|
118
|
+
}
|
|
119
|
+
clear();
|
|
120
|
+
uiState = 'idle';
|
|
121
|
+
},
|
|
122
|
+
cancel() {
|
|
123
|
+
clear();
|
|
124
|
+
uiState = 'idle';
|
|
125
|
+
},
|
|
126
|
+
resetError() {
|
|
127
|
+
errorMessage = '';
|
|
128
|
+
uiState = 'idle';
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
export const useSceneBuilder = () => {
|
|
133
|
+
return getContext(key);
|
|
134
|
+
};
|
package/dist/plugins/index.d.ts
CHANGED
|
@@ -7,5 +7,9 @@ export { default as DrawService } from './DrawService/DrawService.svelte';
|
|
|
7
7
|
export { default as Skybox } from './Skybox/Skybox.svelte';
|
|
8
8
|
export { default as Debug } from './Debug/Debug.svelte';
|
|
9
9
|
export { default as Focus } from './Focus/Focus.svelte';
|
|
10
|
+
export { default as LLMSceneBuilder } from './LLMSceneBuilder/LLMSceneBuilder.svelte';
|
|
11
|
+
export { default as AISettings } from './LLMSceneBuilder/AISettings.svelte';
|
|
12
|
+
export type { InferCallback, ComponentFrameInfo } from './LLMSceneBuilder/useSceneBuilder.svelte';
|
|
13
|
+
export type { FrameDelta } from './LLMSceneBuilder/frameDeltaAdapter';
|
|
10
14
|
export { default as XR } from './XR/XR.svelte';
|
|
11
15
|
export { default as XRSettings } from './XR/XRSettings.svelte';
|
package/dist/plugins/index.js
CHANGED
|
@@ -11,5 +11,8 @@ export { default as Skybox } from './Skybox/Skybox.svelte';
|
|
|
11
11
|
// Debug
|
|
12
12
|
export { default as Debug } from './Debug/Debug.svelte';
|
|
13
13
|
export { default as Focus } from './Focus/Focus.svelte';
|
|
14
|
+
// LLMSceneBuilder
|
|
15
|
+
export { default as LLMSceneBuilder } from './LLMSceneBuilder/LLMSceneBuilder.svelte';
|
|
16
|
+
export { default as AISettings } from './LLMSceneBuilder/AISettings.svelte';
|
|
14
17
|
export { default as XR } from './XR/XR.svelte';
|
|
15
18
|
export { default as XRSettings } from './XR/XRSettings.svelte';
|
package/dist/transform.js
CHANGED
|
@@ -136,6 +136,27 @@ export const matrixToPose = (matrix, pose) => {
|
|
|
136
136
|
* `Frame.svelte` uses to blend live kinematics with user-staged edits;
|
|
137
137
|
* `worldMatrix.ts` premultiplies the result by the parent's `WorldMatrix`.
|
|
138
138
|
*/
|
|
139
|
+
export const poseToEulerDegrees = (pose) => {
|
|
140
|
+
poseToQuaternion(pose, quaternion);
|
|
141
|
+
euler.setFromQuaternion(quaternion, 'ZYX');
|
|
142
|
+
return {
|
|
143
|
+
roll: MathUtils.radToDeg(euler.x),
|
|
144
|
+
pitch: MathUtils.radToDeg(euler.y),
|
|
145
|
+
yaw: MathUtils.radToDeg(euler.z),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
export const applyEulerDeltaToPose = (pose, delta, out) => {
|
|
149
|
+
poseToQuaternion(pose, quaternion);
|
|
150
|
+
euler.setFromQuaternion(quaternion, 'ZYX');
|
|
151
|
+
if (delta.roll !== undefined)
|
|
152
|
+
euler.x = MathUtils.degToRad(delta.roll);
|
|
153
|
+
if (delta.pitch !== undefined)
|
|
154
|
+
euler.y = MathUtils.degToRad(delta.pitch);
|
|
155
|
+
if (delta.yaw !== undefined)
|
|
156
|
+
euler.z = MathUtils.degToRad(delta.yaw);
|
|
157
|
+
quaternion.setFromEuler(euler);
|
|
158
|
+
quaternionToPose(quaternion, out);
|
|
159
|
+
};
|
|
139
160
|
export const composeLocalMatrix = (live, baseline, edited, out) => {
|
|
140
161
|
matA.copy(baseline).invert();
|
|
141
162
|
out.copy(live).multiply(matA).multiply(edited);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viamrobotics/motion-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.0",
|
|
4
4
|
"description": "Motion visualization with Viam",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"vite-plugin-devtools-json": "1.0.0",
|
|
86
86
|
"vite-plugin-glsl": "^1.5.5",
|
|
87
87
|
"vite-plugin-mkcert": "1.17.9",
|
|
88
|
-
"vitest": "3.2.
|
|
88
|
+
"vitest": "3.2.6"
|
|
89
89
|
},
|
|
90
90
|
"peerDependencies": {
|
|
91
91
|
"@ag-grid-community/client-side-row-model": ">=32.3.0",
|
|
@@ -163,13 +163,17 @@
|
|
|
163
163
|
],
|
|
164
164
|
"dependencies": {
|
|
165
165
|
"@bufbuild/protobuf": "1.10.1",
|
|
166
|
+
"@connectrpc/connect": "1.7.0",
|
|
167
|
+
"@connectrpc/connect-web": "1.7.0",
|
|
168
|
+
"@langchain/anthropic": "^1.4.0",
|
|
166
169
|
"@neodrag/svelte": "^2.3.3",
|
|
167
170
|
"d3-force": "^3.0.0",
|
|
168
171
|
"filtrex": "^3.1.0",
|
|
169
172
|
"koota": "0.6.5",
|
|
170
173
|
"lodash-es": "4.18.1",
|
|
171
174
|
"three-mesh-bvh": "^0.9.8",
|
|
172
|
-
"uuid-tool": "^2.0.3"
|
|
175
|
+
"uuid-tool": "^2.0.3",
|
|
176
|
+
"zod": "^4.4.3"
|
|
173
177
|
},
|
|
174
178
|
"scripts": {
|
|
175
179
|
"dev": "concurrently \"pnpm dev:bun\" \"go run cmd/draw-server/main.go -port 3030\"",
|