@viamrobotics/motion-tools 1.21.0 → 1.22.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/FrameConfigUpdater.svelte.d.ts +0 -1
- package/dist/FrameConfigUpdater.svelte.js +6 -24
- package/dist/components/Entities/Pose.svelte +18 -13
- package/dist/components/FileDrop/useFileDrop.svelte.js +16 -2
- package/dist/components/PointerMissBox.svelte +1 -1
- package/dist/components/Scene.svelte +2 -0
- package/dist/components/SceneProviders.svelte +2 -0
- package/dist/components/SelectedTransformControls.svelte +227 -0
- package/dist/components/SelectedTransformControls.svelte.d.ts +3 -0
- package/dist/components/StaticGeometries.svelte +3 -56
- package/dist/components/overlay/Details.svelte +48 -53
- package/dist/components/overlay/dashboard/Button.svelte +4 -2
- package/dist/components/overlay/dashboard/Button.svelte.d.ts +1 -1
- package/dist/components/overlay/dashboard/Dashboard.svelte +43 -33
- package/dist/ecs/traits.d.ts +15 -0
- package/dist/ecs/traits.js +7 -0
- package/dist/editing/FrameEditSession.d.ts +37 -0
- package/dist/editing/FrameEditSession.js +178 -0
- package/dist/hooks/useFrameEditSession.svelte.d.ts +15 -0
- package/dist/hooks/useFrameEditSession.svelte.js +36 -0
- package/dist/hooks/useFrames.svelte.js +37 -2
- package/dist/hooks/usePartConfig.svelte.js +10 -0
- package/dist/hooks/useSettings.svelte.d.ts +1 -2
- package/dist/hooks/useSettings.svelte.js +1 -2
- package/dist/transform.js +13 -0
- package/package.json +3 -3
|
@@ -23,6 +23,5 @@ export declare class FrameConfigUpdater {
|
|
|
23
23
|
setFrameParent: (entity: Entity, parentName: string) => void;
|
|
24
24
|
deleteFrame: (entity: Entity) => void;
|
|
25
25
|
setGeometryType: (entity: Entity, type: "none" | "box" | "sphere" | "capsule") => void;
|
|
26
|
-
private sanitizeFloatValue;
|
|
27
26
|
}
|
|
28
27
|
export {};
|
|
@@ -7,9 +7,7 @@ export class FrameConfigUpdater {
|
|
|
7
7
|
this.removeFrame = removeFrame;
|
|
8
8
|
}
|
|
9
9
|
updateLocalPosition = (entity, position) => {
|
|
10
|
-
const x =
|
|
11
|
-
const y = this.sanitizeFloatValue(position.y);
|
|
12
|
-
const z = this.sanitizeFloatValue(position.z);
|
|
10
|
+
const { x, y, z } = position;
|
|
13
11
|
if (x === undefined && y === undefined && z === undefined)
|
|
14
12
|
return;
|
|
15
13
|
const change = {};
|
|
@@ -28,10 +26,7 @@ export class FrameConfigUpdater {
|
|
|
28
26
|
}
|
|
29
27
|
};
|
|
30
28
|
updateLocalOrientation = (entity, orientation) => {
|
|
31
|
-
const oX =
|
|
32
|
-
const oY = this.sanitizeFloatValue(orientation.oY);
|
|
33
|
-
const oZ = this.sanitizeFloatValue(orientation.oZ);
|
|
34
|
-
const theta = this.sanitizeFloatValue(orientation.theta);
|
|
29
|
+
const { oX, oY, oZ, theta } = orientation;
|
|
35
30
|
if (oX === undefined && oY === undefined && oZ === undefined && theta === undefined) {
|
|
36
31
|
return;
|
|
37
32
|
}
|
|
@@ -57,9 +52,7 @@ export class FrameConfigUpdater {
|
|
|
57
52
|
const parent = entity.get(traits.Parent) ?? 'world';
|
|
58
53
|
const pose = entity.get(traits.EditedPose);
|
|
59
54
|
if (geometry?.type === 'box') {
|
|
60
|
-
const x =
|
|
61
|
-
const y = this.sanitizeFloatValue(geometry.y);
|
|
62
|
-
const z = this.sanitizeFloatValue(geometry.z);
|
|
55
|
+
const { x, y, z } = geometry;
|
|
63
56
|
if (x === undefined && y === undefined && z === undefined)
|
|
64
57
|
return;
|
|
65
58
|
const change = {};
|
|
@@ -76,7 +69,7 @@ export class FrameConfigUpdater {
|
|
|
76
69
|
}
|
|
77
70
|
}
|
|
78
71
|
else if (geometry?.type === 'sphere') {
|
|
79
|
-
const r =
|
|
72
|
+
const { r } = geometry;
|
|
80
73
|
if (r === undefined)
|
|
81
74
|
return;
|
|
82
75
|
entity.set(traits.Sphere, { r });
|
|
@@ -86,8 +79,7 @@ export class FrameConfigUpdater {
|
|
|
86
79
|
}
|
|
87
80
|
}
|
|
88
81
|
else if (geometry?.type === 'capsule') {
|
|
89
|
-
const r =
|
|
90
|
-
const l = this.sanitizeFloatValue(geometry.l);
|
|
82
|
+
const { r, l } = geometry;
|
|
91
83
|
if (r === undefined && l === undefined)
|
|
92
84
|
return;
|
|
93
85
|
const change = {};
|
|
@@ -95,7 +87,7 @@ export class FrameConfigUpdater {
|
|
|
95
87
|
change.r = r;
|
|
96
88
|
if (l !== undefined)
|
|
97
89
|
change.l = l;
|
|
98
|
-
entity.set(traits.Capsule,
|
|
90
|
+
entity.set(traits.Capsule, change);
|
|
99
91
|
const capsule = entity.get(traits.Capsule);
|
|
100
92
|
if (name && capsule && pose) {
|
|
101
93
|
this.updateFrame(name, parent, pose, { type: 'capsule', ...capsule });
|
|
@@ -134,14 +126,4 @@ export class FrameConfigUpdater {
|
|
|
134
126
|
this.updateFrame(name, parent, pose, { type: 'capsule', r: 20, l: 100 });
|
|
135
127
|
}
|
|
136
128
|
};
|
|
137
|
-
sanitizeFloatValue = (value) => {
|
|
138
|
-
if (value === undefined) {
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
const num = Number.parseFloat(value.toFixed(2));
|
|
142
|
-
if (Number.isNaN(num)) {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
return value;
|
|
146
|
-
};
|
|
147
129
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { traits, useTrait } from '../../ecs'
|
|
7
7
|
import { usePartConfig } from '../../hooks/usePartConfig.svelte'
|
|
8
8
|
import { usePose } from '../../hooks/usePose.svelte'
|
|
9
|
-
import {
|
|
9
|
+
import { composeRenderedPose } from '../../transform'
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
12
12
|
entity: Entity
|
|
@@ -25,22 +25,27 @@
|
|
|
25
25
|
() => parent.current
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
if (pose.current === undefined
|
|
30
|
-
return editedPose.current
|
|
31
|
-
}
|
|
28
|
+
$effect.pre(() => {
|
|
29
|
+
if (pose.current === undefined) return
|
|
32
30
|
|
|
33
|
-
if (
|
|
34
|
-
|
|
31
|
+
if (entity.has(traits.LivePose)) {
|
|
32
|
+
entity.set(traits.LivePose, pose.current)
|
|
33
|
+
} else {
|
|
34
|
+
entity.add(traits.LivePose(pose.current))
|
|
35
35
|
}
|
|
36
|
+
})
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// Always render through the live blend: live × network⁻¹ × edited. With
|
|
39
|
+
// `edited === network` (no edits) this collapses to `live`, so the rendered
|
|
40
|
+
// pose tracks the robot's kinematics-resolved position. With edits, the
|
|
41
|
+
// formula composes the staged delta on top of live. Input handlers that
|
|
42
|
+
// drive edits (gizmo onChange, Details panel) compute `edited` such that
|
|
43
|
+
// the blend renders to the user's intent.
|
|
44
|
+
const resolvedPose = $derived.by(() => {
|
|
45
|
+
if (pose.current === undefined || partConfig.hasPendingSave) return editedPose.current
|
|
46
|
+
if (!entityPose.current || !editedPose.current) return undefined
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
const resultMatrix = poseUsePose.multiply(poseNetworkInverse).multiply(poseLocalEditedPose)
|
|
43
|
-
return matrixToPose(resultMatrix)
|
|
48
|
+
return composeRenderedPose(pose.current, entityPose.current, editedPose.current)
|
|
44
49
|
})
|
|
45
50
|
</script>
|
|
46
51
|
|
|
@@ -2,6 +2,9 @@ import { Extensions, parseFileName, Prefixes, readFile } from './file-names';
|
|
|
2
2
|
import { pcdDropper } from './pcd-dropper';
|
|
3
3
|
import { plyDropper } from './ply-dropper';
|
|
4
4
|
import { snapshotDropper } from './snapshot-dropper';
|
|
5
|
+
const hasDraggedFiles = (dataTransfer) => {
|
|
6
|
+
return dataTransfer?.types?.includes('Files') ?? false;
|
|
7
|
+
};
|
|
5
8
|
const createFileDropper = (extension, prefix) => {
|
|
6
9
|
switch (prefix) {
|
|
7
10
|
case Prefixes.Snapshot: {
|
|
@@ -22,11 +25,15 @@ export const useFileDrop = (onSuccess, onError) => {
|
|
|
22
25
|
let dropState = $state('inactive');
|
|
23
26
|
// prevent default to allow drop
|
|
24
27
|
const ondragenter = (event) => {
|
|
28
|
+
if (!hasDraggedFiles(event.dataTransfer))
|
|
29
|
+
return;
|
|
25
30
|
event.preventDefault();
|
|
26
31
|
dropState = 'hovering';
|
|
27
32
|
};
|
|
28
33
|
// prevent default to allow drop
|
|
29
34
|
const ondragover = (event) => {
|
|
35
|
+
if (!hasDraggedFiles(event.dataTransfer))
|
|
36
|
+
return;
|
|
30
37
|
event.preventDefault();
|
|
31
38
|
};
|
|
32
39
|
const ondragleave = (event) => {
|
|
@@ -40,10 +47,17 @@ export const useFileDrop = (onSuccess, onError) => {
|
|
|
40
47
|
dropState = 'inactive';
|
|
41
48
|
};
|
|
42
49
|
const ondrop = (event) => {
|
|
50
|
+
const { dataTransfer } = event;
|
|
51
|
+
if (dataTransfer === null || !hasDraggedFiles(dataTransfer)) {
|
|
52
|
+
dropState = 'inactive';
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
43
55
|
event.preventDefault();
|
|
44
|
-
|
|
56
|
+
const { files } = dataTransfer;
|
|
57
|
+
if (files.length === 0) {
|
|
58
|
+
dropState = 'inactive';
|
|
45
59
|
return;
|
|
46
|
-
|
|
60
|
+
}
|
|
47
61
|
let completed = 0;
|
|
48
62
|
for (const file of files) {
|
|
49
63
|
const fileName = parseFileName(file.name);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import Entities from './Entities/Entities.svelte'
|
|
11
11
|
import Focus from './Focus.svelte'
|
|
12
12
|
import Selected from './Selected.svelte'
|
|
13
|
+
import SelectedTransformControls from './SelectedTransformControls.svelte'
|
|
13
14
|
import StaticGeometries from './StaticGeometries.svelte'
|
|
14
15
|
import { useFocusedObject3d } from '../hooks/useSelection.svelte'
|
|
15
16
|
import { useSettings } from '../hooks/useSettings.svelte'
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
|
|
78
79
|
<StaticGeometries />
|
|
79
80
|
<Selected />
|
|
81
|
+
<SelectedTransformControls />
|
|
80
82
|
|
|
81
83
|
{#if !$isPresenting && settings.current.grid}
|
|
82
84
|
<Grid
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
} from '../hooks/useControls.svelte'
|
|
13
13
|
import { provideDrawAPI } from '../hooks/useDrawAPI.svelte'
|
|
14
14
|
import { provideDrawService } from '../hooks/useDrawService.svelte'
|
|
15
|
+
import { provideFrameEditSession } from '../hooks/useFrameEditSession.svelte'
|
|
15
16
|
import { provideFramelessComponents } from '../hooks/useFramelessComponents.svelte'
|
|
16
17
|
import { provideFrames } from '../hooks/useFrames.svelte'
|
|
17
18
|
import { provideGeometries } from '../hooks/useGeometries.svelte'
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
|
|
48
49
|
provideResourceByName(() => partID.current)
|
|
49
50
|
provideConfigFrames()
|
|
51
|
+
provideFrameEditSession(() => partID.current)
|
|
50
52
|
provideFrames(() => partID.current)
|
|
51
53
|
provideGeometries(() => partID.current)
|
|
52
54
|
provide3DModels(() => partID.current)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { TransformControls } from '@threlte/extras'
|
|
3
|
+
import { Quaternion, Vector3 } from 'three'
|
|
4
|
+
|
|
5
|
+
import type { FrameEditSession } from '../editing/FrameEditSession'
|
|
6
|
+
|
|
7
|
+
import { traits, useTrait } from '../ecs'
|
|
8
|
+
import { useTransformControls } from '../hooks/useControls.svelte'
|
|
9
|
+
import { useEnvironment } from '../hooks/useEnvironment.svelte'
|
|
10
|
+
import { useFrameEditSession } from '../hooks/useFrameEditSession.svelte'
|
|
11
|
+
import { useSelectedEntity, useSelectedObject3d } from '../hooks/useSelection.svelte'
|
|
12
|
+
import { useSettings } from '../hooks/useSettings.svelte'
|
|
13
|
+
import {
|
|
14
|
+
composeEditedPoseForRenderedPose,
|
|
15
|
+
createPose,
|
|
16
|
+
quaternionToPose,
|
|
17
|
+
vector3ToPose,
|
|
18
|
+
} from '../transform'
|
|
19
|
+
|
|
20
|
+
const settings = useSettings()
|
|
21
|
+
const environment = useEnvironment()
|
|
22
|
+
const transformControls = useTransformControls()
|
|
23
|
+
const selectedEntity = useSelectedEntity()
|
|
24
|
+
const selectedObject3d = useSelectedObject3d()
|
|
25
|
+
const sessions = useFrameEditSession()
|
|
26
|
+
|
|
27
|
+
const mode = $derived(settings.current.transformMode)
|
|
28
|
+
const entity = $derived(selectedEntity.current)
|
|
29
|
+
const transformable = useTrait(() => entity, traits.Transformable)
|
|
30
|
+
const networkPose = useTrait(() => entity, traits.Pose)
|
|
31
|
+
const livePose = useTrait(() => entity, traits.LivePose)
|
|
32
|
+
const box = useTrait(() => entity, traits.Box)
|
|
33
|
+
const sphere = useTrait(() => entity, traits.Sphere)
|
|
34
|
+
const capsule = useTrait(() => entity, traits.Capsule)
|
|
35
|
+
const hasScalableGeometry = $derived(
|
|
36
|
+
box.current !== undefined || sphere.current !== undefined || capsule.current !== undefined
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// Mesh sets name={entity} on its inner mesh, so useSelectedObject3d resolves
|
|
40
|
+
// to that mesh — not the parent Frame Group we actually want to drive. Walk
|
|
41
|
+
// up to the Group so translate/rotate/scale apply to the whole frame, not
|
|
42
|
+
// the geometry inside it.
|
|
43
|
+
const ref = $derived(selectedObject3d.current?.parent ?? selectedObject3d.current)
|
|
44
|
+
|
|
45
|
+
const activeMode = $derived.by(() => {
|
|
46
|
+
if (mode === 'none' || !transformable.current) return undefined
|
|
47
|
+
// Scale only does anything for primitive geometries the gizmo can size.
|
|
48
|
+
if (mode === 'scale' && !hasScalableGeometry) return undefined
|
|
49
|
+
return mode
|
|
50
|
+
})
|
|
51
|
+
const isSphereScale = $derived(activeMode === 'scale' && sphere.current !== undefined)
|
|
52
|
+
const isCapsuleScale = $derived(activeMode === 'scale' && capsule.current !== undefined)
|
|
53
|
+
|
|
54
|
+
const quaternion = new Quaternion()
|
|
55
|
+
const vector3 = new Vector3()
|
|
56
|
+
const refPose = createPose()
|
|
57
|
+
|
|
58
|
+
let session: FrameEditSession | undefined
|
|
59
|
+
let scaleStart:
|
|
60
|
+
| { type: 'box'; x: number; y: number; z: number }
|
|
61
|
+
| { type: 'sphere'; r: number }
|
|
62
|
+
| { type: 'capsule'; r: number; l: number }
|
|
63
|
+
| undefined
|
|
64
|
+
|
|
65
|
+
const captureScaleStart = () => {
|
|
66
|
+
if (!entity || activeMode !== 'scale') {
|
|
67
|
+
scaleStart = undefined
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const box = entity.get(traits.Box)
|
|
72
|
+
if (box) {
|
|
73
|
+
scaleStart = { type: 'box', ...box }
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sphere = entity.get(traits.Sphere)
|
|
78
|
+
if (sphere) {
|
|
79
|
+
scaleStart = { type: 'sphere', ...sphere }
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const capsule = entity.get(traits.Capsule)
|
|
84
|
+
if (capsule) {
|
|
85
|
+
scaleStart = { type: 'capsule', ...capsule }
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
scaleStart = undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const onMouseDown = () => {
|
|
93
|
+
if (entity?.has(traits.FramesAPI)) {
|
|
94
|
+
session = sessions.begin([entity])
|
|
95
|
+
}
|
|
96
|
+
captureScaleStart()
|
|
97
|
+
environment.current.viewerMode = 'edit'
|
|
98
|
+
transformControls.setActive(true)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onChange = () => {
|
|
102
|
+
if (!ref || !entity || !activeMode) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const isFrameEntity = entity.has(traits.FramesAPI)
|
|
107
|
+
|
|
108
|
+
if (activeMode === 'translate' || activeMode === 'rotate') {
|
|
109
|
+
if (isFrameEntity) {
|
|
110
|
+
stageFrameTransform()
|
|
111
|
+
} else {
|
|
112
|
+
const pose = entity.get(traits.Pose)
|
|
113
|
+
if (pose) {
|
|
114
|
+
if (activeMode === 'translate') {
|
|
115
|
+
vector3ToPose(ref.getWorldPosition(vector3), pose)
|
|
116
|
+
} else {
|
|
117
|
+
quaternionToPose(ref.getWorldQuaternion(quaternion), pose)
|
|
118
|
+
ref.quaternion.copy(quaternion)
|
|
119
|
+
}
|
|
120
|
+
entity.set(traits.Pose, pose)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
// scale → bake the gizmo's scale factor into the geometry trait,
|
|
125
|
+
// then reset the object's scale so subsequent drags start from 1.
|
|
126
|
+
if (!scaleStart) {
|
|
127
|
+
captureScaleStart()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (scaleStart?.type === 'box') {
|
|
131
|
+
const next = {
|
|
132
|
+
x: scaleStart.x * ref.scale.x,
|
|
133
|
+
y: scaleStart.y * ref.scale.y,
|
|
134
|
+
z: scaleStart.z * ref.scale.z,
|
|
135
|
+
}
|
|
136
|
+
if (isFrameEntity) {
|
|
137
|
+
session?.stageGeometry(entity, { type: 'box', ...next })
|
|
138
|
+
} else {
|
|
139
|
+
entity.set(traits.Box, next)
|
|
140
|
+
}
|
|
141
|
+
} else if (scaleStart?.type === 'sphere') {
|
|
142
|
+
const next = { r: scaleStart.r * ref.scale.x }
|
|
143
|
+
if (isFrameEntity) {
|
|
144
|
+
session?.stageGeometry(entity, { type: 'sphere', ...next })
|
|
145
|
+
} else {
|
|
146
|
+
entity.set(traits.Sphere, next)
|
|
147
|
+
}
|
|
148
|
+
} else if (scaleStart?.type === 'capsule') {
|
|
149
|
+
const next = { r: scaleStart.r * ref.scale.x, l: scaleStart.l * ref.scale.y }
|
|
150
|
+
if (isFrameEntity) {
|
|
151
|
+
session?.stageGeometry(entity, { type: 'capsule', ...next })
|
|
152
|
+
} else {
|
|
153
|
+
entity.set(traits.Capsule, next)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ref.scale.setScalar(1)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const onMouseUp = () => {
|
|
162
|
+
session?.commit()
|
|
163
|
+
session = undefined
|
|
164
|
+
scaleStart = undefined
|
|
165
|
+
transformControls.setActive(false)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pose.svelte renders frame entities through the live blend
|
|
169
|
+
// render = M(live) × M(network)⁻¹ × M(edited)
|
|
170
|
+
// so for the user's drag to render where they pulled the gizmo to, EditedPose
|
|
171
|
+
// must satisfy
|
|
172
|
+
// M(edited) = M(network) × M(live)⁻¹ × M(ref)
|
|
173
|
+
// where M(ref) is the gizmo-driven group's parent-relative matrix in mm.
|
|
174
|
+
// When live ≈ network (no kinematic offset), this collapses to M(edited) =
|
|
175
|
+
// M(ref) — the same as the naive writeback. When they diverge (e.g. an arm
|
|
176
|
+
// whose joints have moved away from its config pose), this composition is
|
|
177
|
+
// what keeps the rendering anchored to the user's pointer instead of
|
|
178
|
+
// shearing through the live × baseline⁻¹ offset.
|
|
179
|
+
const stageFrameTransform = () => {
|
|
180
|
+
if (!ref || !entity) return
|
|
181
|
+
|
|
182
|
+
vector3ToPose(ref.position, refPose)
|
|
183
|
+
quaternionToPose(ref.quaternion, refPose)
|
|
184
|
+
|
|
185
|
+
const live = livePose.current
|
|
186
|
+
const network = networkPose.current
|
|
187
|
+
|
|
188
|
+
if (!live || !network) {
|
|
189
|
+
// No live pose available — Pose.svelte's blend short-circuits to
|
|
190
|
+
// editedPose, so naive writeback is correct.
|
|
191
|
+
if (activeMode === 'translate') {
|
|
192
|
+
session?.stagePose(entity, {
|
|
193
|
+
x: refPose.x,
|
|
194
|
+
y: refPose.y,
|
|
195
|
+
z: refPose.z,
|
|
196
|
+
})
|
|
197
|
+
} else if (activeMode === 'rotate') {
|
|
198
|
+
session?.stagePose(entity, {
|
|
199
|
+
oX: refPose.oX,
|
|
200
|
+
oY: refPose.oY,
|
|
201
|
+
oZ: refPose.oZ,
|
|
202
|
+
theta: refPose.theta,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
session?.stagePose(entity, composeEditedPoseForRenderedPose(network, live, refPose))
|
|
209
|
+
}
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
{#if ref && entity && activeMode}
|
|
213
|
+
{#key entity}
|
|
214
|
+
<TransformControls
|
|
215
|
+
object={ref}
|
|
216
|
+
mode={activeMode}
|
|
217
|
+
translationSnap={settings.current.snapping ? 0.1 : undefined}
|
|
218
|
+
rotationSnap={settings.current.snapping ? Math.PI / 24 : undefined}
|
|
219
|
+
scaleSnap={settings.current.snapping ? 0.1 : undefined}
|
|
220
|
+
showY={!isSphereScale}
|
|
221
|
+
showZ={!isSphereScale && !isCapsuleScale}
|
|
222
|
+
onmouseDown={onMouseDown}
|
|
223
|
+
onobjectChange={onChange}
|
|
224
|
+
onmouseUp={onMouseUp}
|
|
225
|
+
/>
|
|
226
|
+
{/key}
|
|
227
|
+
{/if}
|
|
@@ -8,22 +8,15 @@
|
|
|
8
8
|
<script lang="ts">
|
|
9
9
|
import type { Entity } from 'koota'
|
|
10
10
|
|
|
11
|
-
import { TransformControls } from '@threlte/extras'
|
|
12
11
|
import { PressedKeys } from 'runed'
|
|
13
12
|
import { SvelteSet } from 'svelte/reactivity'
|
|
14
|
-
import { Quaternion, Vector3 } from 'three'
|
|
15
13
|
|
|
16
14
|
import { traits, useWorld } from '../ecs'
|
|
17
|
-
import { useTransformControls } from '../hooks/useControls.svelte'
|
|
18
15
|
import { useSelectedEntity } from '../hooks/useSelection.svelte'
|
|
19
|
-
import { useSettings } from '../hooks/useSettings.svelte'
|
|
20
|
-
import { quaternionToPose, vector3ToPose } from '../transform'
|
|
21
16
|
|
|
22
17
|
import Frame from './Entities/Frame.svelte'
|
|
23
18
|
|
|
24
19
|
const world = useWorld()
|
|
25
|
-
const settings = useSettings()
|
|
26
|
-
const transformControls = useTransformControls()
|
|
27
20
|
const selectedEntity = useSelectedEntity()
|
|
28
21
|
|
|
29
22
|
const entities = new SvelteSet<Entity>()
|
|
@@ -31,11 +24,6 @@
|
|
|
31
24
|
[...entities].find((entity) => entity === selectedEntity.current)
|
|
32
25
|
)
|
|
33
26
|
|
|
34
|
-
const mode = $derived(settings.current.transformMode)
|
|
35
|
-
|
|
36
|
-
const quaternion = new Quaternion()
|
|
37
|
-
const vector3 = new Vector3()
|
|
38
|
-
|
|
39
27
|
const keys = new PressedKeys()
|
|
40
28
|
|
|
41
29
|
keys.onKeys('=', () => {
|
|
@@ -43,7 +31,8 @@
|
|
|
43
31
|
traits.Name(`custom geometry ${++index}`),
|
|
44
32
|
traits.Pose,
|
|
45
33
|
traits.Box({ x: 100, y: 100, z: 100 }),
|
|
46
|
-
traits.Removable
|
|
34
|
+
traits.Removable,
|
|
35
|
+
traits.Transformable
|
|
47
36
|
)
|
|
48
37
|
|
|
49
38
|
entities.add(entity)
|
|
@@ -57,50 +46,8 @@
|
|
|
57
46
|
selectedEntity.set()
|
|
58
47
|
}
|
|
59
48
|
})
|
|
60
|
-
|
|
61
|
-
$effect(() => {
|
|
62
|
-
settings.current.transforming = selectedCustomGeometry !== undefined
|
|
63
|
-
})
|
|
64
49
|
</script>
|
|
65
50
|
|
|
66
51
|
{#each entities as entity (entity)}
|
|
67
|
-
<Frame {entity}
|
|
68
|
-
{#snippet children({ ref })}
|
|
69
|
-
{#if selectedEntity.current === entity}
|
|
70
|
-
{#key mode}
|
|
71
|
-
<TransformControls
|
|
72
|
-
object={ref}
|
|
73
|
-
{mode}
|
|
74
|
-
translationSnap={settings.current.snapping ? 0.1 : undefined}
|
|
75
|
-
rotationSnap={settings.current.snapping ? Math.PI / 24 : undefined}
|
|
76
|
-
scaleSnap={settings.current.snapping ? 0.1 : undefined}
|
|
77
|
-
onmouseDown={() => {
|
|
78
|
-
transformControls.setActive(true)
|
|
79
|
-
}}
|
|
80
|
-
onmouseUp={() => {
|
|
81
|
-
transformControls.setActive(false)
|
|
82
|
-
|
|
83
|
-
const pose = entity.get(traits.Pose)
|
|
84
|
-
const box = entity.get(traits.Box)
|
|
85
|
-
|
|
86
|
-
if (pose && mode === 'translate') {
|
|
87
|
-
vector3ToPose(ref.getWorldPosition(vector3), pose)
|
|
88
|
-
entity.set(traits.Pose, pose)
|
|
89
|
-
} else if (pose && mode === 'rotate') {
|
|
90
|
-
quaternionToPose(ref.getWorldQuaternion(quaternion), pose)
|
|
91
|
-
ref.quaternion.copy(quaternion)
|
|
92
|
-
entity.set(traits.Pose, pose)
|
|
93
|
-
} else if (box && mode === 'scale') {
|
|
94
|
-
box.x *= ref.scale.x
|
|
95
|
-
box.y *= ref.scale.y
|
|
96
|
-
box.z *= ref.scale.z
|
|
97
|
-
entity.set(traits.Box, box)
|
|
98
|
-
ref.scale.setScalar(1)
|
|
99
|
-
}
|
|
100
|
-
}}
|
|
101
|
-
/>
|
|
102
|
-
{/key}
|
|
103
|
-
{/if}
|
|
104
|
-
{/snippet}
|
|
105
|
-
</Frame>
|
|
52
|
+
<Frame {entity} />
|
|
106
53
|
{/each}
|
|
@@ -11,12 +11,6 @@
|
|
|
11
11
|
const quaternion = new Quaternion()
|
|
12
12
|
const ov = new OrientationVector()
|
|
13
13
|
const euler = new Euler()
|
|
14
|
-
|
|
15
|
-
ThemeUtils.setGlobalDefaultTheme({
|
|
16
|
-
...ThemeUtils.presets.light,
|
|
17
|
-
baseBackgroundColor: '#fbfbfc',
|
|
18
|
-
baseShadowColor: 'transparent',
|
|
19
|
-
})
|
|
20
14
|
</script>
|
|
21
15
|
|
|
22
16
|
<script lang="ts">
|
|
@@ -139,8 +133,6 @@
|
|
|
139
133
|
}
|
|
140
134
|
})
|
|
141
135
|
|
|
142
|
-
const formatTwoDecimals = (value: number) => value.toFixed(2)
|
|
143
|
-
|
|
144
136
|
const detailConfigUpdater = new FrameConfigUpdater(partConfig.updateFrame, partConfig.deleteFrame)
|
|
145
137
|
|
|
146
138
|
const handlePositionChange = (event: PointChangeEvent) => {
|
|
@@ -295,6 +287,12 @@
|
|
|
295
287
|
2
|
|
296
288
|
)
|
|
297
289
|
}
|
|
290
|
+
|
|
291
|
+
ThemeUtils.setGlobalDefaultTheme({
|
|
292
|
+
...ThemeUtils.presets.light,
|
|
293
|
+
baseBackgroundColor: '#fbfbfc',
|
|
294
|
+
baseShadowColor: 'transparent',
|
|
295
|
+
})
|
|
298
296
|
</script>
|
|
299
297
|
|
|
300
298
|
{#snippet ImmutableField({
|
|
@@ -321,9 +319,7 @@
|
|
|
321
319
|
{#if entity}
|
|
322
320
|
<div
|
|
323
321
|
id="details-panel"
|
|
324
|
-
class="border-medium bg-extralight absolute top-0 right-0 z-4 m-2
|
|
325
|
-
? 'w-80'
|
|
326
|
-
: 'w-60'} border p-2 text-xs dark:text-black"
|
|
322
|
+
class="border-medium bg-extralight absolute top-0 right-0 z-4 m-2 w-70 border p-2 text-xs dark:text-black"
|
|
327
323
|
use:draggable={{
|
|
328
324
|
bounds: 'body',
|
|
329
325
|
handle: dragElement,
|
|
@@ -492,7 +488,6 @@
|
|
|
492
488
|
y: localPose.current.y,
|
|
493
489
|
z: localPose.current.z,
|
|
494
490
|
}}
|
|
495
|
-
format={formatTwoDecimals}
|
|
496
491
|
on:change={handlePositionChange}
|
|
497
492
|
/>
|
|
498
493
|
</div>
|
|
@@ -531,7 +526,6 @@
|
|
|
531
526
|
z: localPose.current.oZ,
|
|
532
527
|
w: localPose.current.theta,
|
|
533
528
|
}}
|
|
534
|
-
format={formatTwoDecimals}
|
|
535
529
|
on:change={handleOrientationOVChange}
|
|
536
530
|
/>
|
|
537
531
|
</TabPage>
|
|
@@ -578,48 +572,49 @@
|
|
|
578
572
|
<div aria-label="mutable geometry">
|
|
579
573
|
<TabGroup bind:selectedIndex={geometryTabIndex}>
|
|
580
574
|
<TabPage title="None" />
|
|
581
|
-
<TabPage title="Box"
|
|
582
|
-
|
|
583
|
-
|
|
575
|
+
<TabPage title="Box">
|
|
576
|
+
{#if box.current}
|
|
577
|
+
<div aria-label="mutable box dimensions">
|
|
578
|
+
<Point
|
|
579
|
+
value={{
|
|
580
|
+
x: box.current.x,
|
|
581
|
+
y: box.current.y,
|
|
582
|
+
z: box.current.z,
|
|
583
|
+
}}
|
|
584
|
+
on:change={handleBoxChange}
|
|
585
|
+
/>
|
|
586
|
+
</div>
|
|
587
|
+
{/if}
|
|
588
|
+
</TabPage>
|
|
589
|
+
<TabPage title="Sphere">
|
|
590
|
+
{#if sphere.current}
|
|
591
|
+
<div aria-label="mutable sphere dimensions">
|
|
592
|
+
<Slider
|
|
593
|
+
label="r"
|
|
594
|
+
value={sphere.current.r}
|
|
595
|
+
on:change={handleSphereRChange}
|
|
596
|
+
/>
|
|
597
|
+
</div>
|
|
598
|
+
{/if}
|
|
599
|
+
</TabPage>
|
|
600
|
+
<TabPage title="Capsule">
|
|
601
|
+
{#if capsule.current}
|
|
602
|
+
<div aria-label="mutable capsule dimensions">
|
|
603
|
+
<Slider
|
|
604
|
+
label="r"
|
|
605
|
+
value={capsule.current.r}
|
|
606
|
+
on:change={handleCapsuleRChange}
|
|
607
|
+
/>
|
|
608
|
+
<Slider
|
|
609
|
+
label="l"
|
|
610
|
+
value={capsule.current.l}
|
|
611
|
+
on:change={handleCapsuleLChange}
|
|
612
|
+
/>
|
|
613
|
+
</div>
|
|
614
|
+
{/if}
|
|
615
|
+
</TabPage>
|
|
584
616
|
</TabGroup>
|
|
585
617
|
</div>
|
|
586
|
-
{#if geometryTabIndex === 1 && box.current}
|
|
587
|
-
<div aria-label="mutable box dimensions">
|
|
588
|
-
<Point
|
|
589
|
-
value={{
|
|
590
|
-
x: box.current.x,
|
|
591
|
-
y: box.current.y,
|
|
592
|
-
z: box.current.z,
|
|
593
|
-
}}
|
|
594
|
-
format={formatTwoDecimals}
|
|
595
|
-
on:change={handleBoxChange}
|
|
596
|
-
/>
|
|
597
|
-
</div>
|
|
598
|
-
{:else if geometryTabIndex === 2 && sphere.current}
|
|
599
|
-
<div aria-label="mutable sphere dimensions">
|
|
600
|
-
<Slider
|
|
601
|
-
label="r"
|
|
602
|
-
value={sphere.current.r}
|
|
603
|
-
format={formatTwoDecimals}
|
|
604
|
-
on:change={handleSphereRChange}
|
|
605
|
-
/>
|
|
606
|
-
</div>
|
|
607
|
-
{:else if geometryTabIndex === 3 && capsule.current}
|
|
608
|
-
<div aria-label="mutable capsule dimensions">
|
|
609
|
-
<Slider
|
|
610
|
-
label="r"
|
|
611
|
-
value={capsule.current.r}
|
|
612
|
-
format={formatTwoDecimals}
|
|
613
|
-
on:change={handleCapsuleRChange}
|
|
614
|
-
/>
|
|
615
|
-
<Slider
|
|
616
|
-
label="l"
|
|
617
|
-
value={capsule.current.l}
|
|
618
|
-
format={formatTwoDecimals}
|
|
619
|
-
on:change={handleCapsuleLChange}
|
|
620
|
-
/>
|
|
621
|
-
</div>
|
|
622
|
-
{/if}
|
|
623
618
|
</div>
|
|
624
619
|
{:else if box.current}
|
|
625
620
|
<div>
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
|
|
3
3
|
|
|
4
4
|
import { Icon, type IconName, Tooltip } from '@viamrobotics/prime-core'
|
|
5
|
-
import { Ruler } from 'lucide-svelte'
|
|
5
|
+
import { MousePointer2, Ruler } from 'lucide-svelte'
|
|
6
6
|
|
|
7
7
|
interface Props extends HTMLButtonAttributes {
|
|
8
|
-
icon: IconName | 'ruler'
|
|
8
|
+
icon: IconName | 'ruler' | 'mouse-pointer'
|
|
9
9
|
active?: boolean
|
|
10
10
|
description: string
|
|
11
11
|
hotkey?: string
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
>
|
|
49
49
|
{#if icon === 'ruler'}
|
|
50
50
|
<Ruler size="16" />
|
|
51
|
+
{:else if icon === 'mouse-pointer'}
|
|
52
|
+
<MousePointer2 size="16" />
|
|
51
53
|
{:else}
|
|
52
54
|
<Icon name={icon} />
|
|
53
55
|
{/if}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements';
|
|
2
2
|
import { type IconName } from '@viamrobotics/prime-core';
|
|
3
3
|
interface Props extends HTMLButtonAttributes {
|
|
4
|
-
icon: IconName | 'ruler';
|
|
4
|
+
icon: IconName | 'ruler' | 'mouse-pointer';
|
|
5
5
|
active?: boolean;
|
|
6
6
|
description: string;
|
|
7
7
|
hotkey?: string;
|
|
@@ -38,40 +38,50 @@
|
|
|
38
38
|
</fieldset>
|
|
39
39
|
|
|
40
40
|
<!-- transform -->
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
41
|
+
<fieldset class="flex">
|
|
42
|
+
<Button
|
|
43
|
+
icon="mouse-pointer"
|
|
44
|
+
active={settings.current.transformMode === 'none'}
|
|
45
|
+
description="No transform controls"
|
|
46
|
+
hotkey="0"
|
|
47
|
+
onclick={() => {
|
|
48
|
+
settings.current.transformMode = 'none'
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
<Button
|
|
52
|
+
icon="cursor-move"
|
|
53
|
+
active={settings.current.transformMode === 'translate'}
|
|
54
|
+
description="Translate"
|
|
55
|
+
hotkey="1"
|
|
56
|
+
class="-ml-px"
|
|
57
|
+
onclick={() => {
|
|
58
|
+
settings.current.transformMode = 'translate'
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
<Button
|
|
62
|
+
icon="sync"
|
|
63
|
+
active={settings.current.transformMode === 'rotate'}
|
|
64
|
+
description="Rotate"
|
|
65
|
+
hotkey="2"
|
|
66
|
+
class="-ml-px"
|
|
67
|
+
onclick={() => {
|
|
68
|
+
settings.current.transformMode = 'rotate'
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
<Button
|
|
72
|
+
icon="resize"
|
|
73
|
+
active={settings.current.transformMode === 'scale'}
|
|
74
|
+
description="Scale"
|
|
75
|
+
hotkey="3"
|
|
76
|
+
class="-ml-px"
|
|
77
|
+
onclick={() => {
|
|
78
|
+
settings.current.transformMode = 'scale'
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</fieldset>
|
|
73
82
|
|
|
74
|
-
|
|
83
|
+
<!-- snapping -->
|
|
84
|
+
{#if settings.current.transformMode !== 'none'}
|
|
75
85
|
<fieldset class="flex">
|
|
76
86
|
<Button
|
|
77
87
|
icon={settings.current.snapping ? 'magnet' : 'magnet-off'}
|
package/dist/ecs/traits.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export declare const EditedPose: import("koota").Trait<{
|
|
|
23
23
|
oZ: number;
|
|
24
24
|
theta: number;
|
|
25
25
|
}>;
|
|
26
|
+
export declare const LivePose: import("koota").Trait<{
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
z: number;
|
|
30
|
+
oX: number;
|
|
31
|
+
oY: number;
|
|
32
|
+
oZ: number;
|
|
33
|
+
theta: number;
|
|
34
|
+
}>;
|
|
26
35
|
export declare const Center: import("koota").Trait<{
|
|
27
36
|
x: number;
|
|
28
37
|
y: number;
|
|
@@ -146,6 +155,12 @@ export declare const SnapshotAPI: import("koota").Trait<() => boolean>;
|
|
|
146
155
|
* Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
|
|
147
156
|
*/
|
|
148
157
|
export declare const DroppedFile: import("koota").Trait<() => boolean>;
|
|
158
|
+
/**
|
|
159
|
+
* Marker trait for entities the dashboard's TransformControls may attach to —
|
|
160
|
+
* editable frames and ad-hoc custom geometries. Other entity kinds (lines,
|
|
161
|
+
* points, batched arrows, etc.) are deliberately excluded.
|
|
162
|
+
*/
|
|
163
|
+
export declare const Transformable: import("koota").Trait<() => boolean>;
|
|
149
164
|
export declare const ShowAxesHelper: import("koota").Trait<() => boolean>;
|
|
150
165
|
/**
|
|
151
166
|
* Marker trait for entities that should be rendered in screen space (CSS pixels)
|
package/dist/ecs/traits.js
CHANGED
|
@@ -11,6 +11,7 @@ export const Parent = trait(() => 'world');
|
|
|
11
11
|
export const UUID = trait(() => '');
|
|
12
12
|
export const Pose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
|
|
13
13
|
export const EditedPose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
|
|
14
|
+
export const LivePose = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
|
|
14
15
|
export const Center = trait({ x: 0, y: 0, z: 0, oX: 0, oY: 0, oZ: 1, theta: 0 });
|
|
15
16
|
export const InstancedPose = trait({
|
|
16
17
|
x: 0,
|
|
@@ -103,6 +104,12 @@ export const SnapshotAPI = trait(() => true);
|
|
|
103
104
|
* Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
|
|
104
105
|
*/
|
|
105
106
|
export const DroppedFile = trait(() => true);
|
|
107
|
+
/**
|
|
108
|
+
* Marker trait for entities the dashboard's TransformControls may attach to —
|
|
109
|
+
* editable frames and ad-hoc custom geometries. Other entity kinds (lines,
|
|
110
|
+
* points, batched arrows, etc.) are deliberately excluded.
|
|
111
|
+
*/
|
|
112
|
+
export const Transformable = trait(() => true);
|
|
106
113
|
export const ShowAxesHelper = trait(() => true);
|
|
107
114
|
/**
|
|
108
115
|
* Marker trait for entities that should be rendered in screen space (CSS pixels)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Pose } from '@viamrobotics/sdk';
|
|
2
|
+
import type { Entity } from 'koota';
|
|
3
|
+
import type { Frame } from '../frame';
|
|
4
|
+
export type UpdateFrameFn = (componentName: string, referenceFrame: string, pose: Pose, geometry?: Frame['geometry']) => void;
|
|
5
|
+
export type DeleteFrameFn = (componentName: string) => void;
|
|
6
|
+
/**
|
|
7
|
+
* A single user gesture against one or more frames (drag, parent change, geometry tweak).
|
|
8
|
+
* Owns the affected entities until commit() or abort() runs. Snapshots their pre-gesture
|
|
9
|
+
* trait state so abort() can restore — both the ECS view and the dirty part config.
|
|
10
|
+
*
|
|
11
|
+
* Replaces the Transforming marker trait: while a session is active, useFrames asks
|
|
12
|
+
* `session.owns(entity)` instead of inspecting a per-entity flag.
|
|
13
|
+
*/
|
|
14
|
+
export declare class FrameEditSession {
|
|
15
|
+
#private;
|
|
16
|
+
private snapshots;
|
|
17
|
+
private updateFrame;
|
|
18
|
+
private deleteFrame;
|
|
19
|
+
private onClose;
|
|
20
|
+
constructor(entities: Entity[], updateFrame: UpdateFrameFn, deleteFrame: DeleteFrameFn, onClose: () => void);
|
|
21
|
+
get isClosed(): boolean;
|
|
22
|
+
owns(entity: Entity | undefined): boolean;
|
|
23
|
+
stagePose: (entity: Entity, pose: Partial<Pose>) => void;
|
|
24
|
+
stageGeometry: (entity: Entity, geometry: Frame["geometry"]) => void;
|
|
25
|
+
stageParent: (entity: Entity, parent: string) => void;
|
|
26
|
+
stageDelete: (entity: Entity) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Validate and close. Returns true on success. On invalid pose data
|
|
29
|
+
* (NaN/infinite from a degenerate gizmo state), aborts and returns false.
|
|
30
|
+
*/
|
|
31
|
+
commit: () => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Restore each owned entity's traits to its pre-session state and re-issue
|
|
34
|
+
* an updateFrame so the dirty part config matches.
|
|
35
|
+
*/
|
|
36
|
+
abort: () => void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { traits } from '../ecs';
|
|
2
|
+
import { isFinitePose } from '../transform';
|
|
3
|
+
const captureGeometry = (entity) => {
|
|
4
|
+
const box = entity.get(traits.Box);
|
|
5
|
+
if (box)
|
|
6
|
+
return { type: 'box', box: { ...box } };
|
|
7
|
+
const sphere = entity.get(traits.Sphere);
|
|
8
|
+
if (sphere)
|
|
9
|
+
return { type: 'sphere', sphere: { ...sphere } };
|
|
10
|
+
const capsule = entity.get(traits.Capsule);
|
|
11
|
+
if (capsule)
|
|
12
|
+
return { type: 'capsule', capsule: { ...capsule } };
|
|
13
|
+
return { type: 'none' };
|
|
14
|
+
};
|
|
15
|
+
const restoreGeometryTrait = (entity, snap) => {
|
|
16
|
+
if (snap.type === 'none') {
|
|
17
|
+
entity.remove(traits.Box, traits.Sphere, traits.Capsule);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (snap.type === 'box' && snap.box) {
|
|
21
|
+
entity.remove(traits.Sphere, traits.Capsule);
|
|
22
|
+
if (entity.has(traits.Box))
|
|
23
|
+
entity.set(traits.Box, snap.box);
|
|
24
|
+
else
|
|
25
|
+
entity.add(traits.Box(snap.box));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (snap.type === 'sphere' && snap.sphere) {
|
|
29
|
+
entity.remove(traits.Box, traits.Capsule);
|
|
30
|
+
if (entity.has(traits.Sphere))
|
|
31
|
+
entity.set(traits.Sphere, snap.sphere);
|
|
32
|
+
else
|
|
33
|
+
entity.add(traits.Sphere(snap.sphere));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (snap.type === 'capsule' && snap.capsule) {
|
|
37
|
+
entity.remove(traits.Box, traits.Sphere);
|
|
38
|
+
if (entity.has(traits.Capsule))
|
|
39
|
+
entity.set(traits.Capsule, snap.capsule);
|
|
40
|
+
else
|
|
41
|
+
entity.add(traits.Capsule(snap.capsule));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const snapshotToFrameGeometry = (snap) => {
|
|
45
|
+
if (snap.type === 'box' && snap.box)
|
|
46
|
+
return { type: 'box', ...snap.box };
|
|
47
|
+
if (snap.type === 'sphere' && snap.sphere)
|
|
48
|
+
return { type: 'sphere', ...snap.sphere };
|
|
49
|
+
if (snap.type === 'capsule' && snap.capsule)
|
|
50
|
+
return { type: 'capsule', ...snap.capsule };
|
|
51
|
+
return { type: 'none' };
|
|
52
|
+
};
|
|
53
|
+
const liveGeometry = (entity) => snapshotToFrameGeometry(captureGeometry(entity));
|
|
54
|
+
/**
|
|
55
|
+
* A single user gesture against one or more frames (drag, parent change, geometry tweak).
|
|
56
|
+
* Owns the affected entities until commit() or abort() runs. Snapshots their pre-gesture
|
|
57
|
+
* trait state so abort() can restore — both the ECS view and the dirty part config.
|
|
58
|
+
*
|
|
59
|
+
* Replaces the Transforming marker trait: while a session is active, useFrames asks
|
|
60
|
+
* `session.owns(entity)` instead of inspecting a per-entity flag.
|
|
61
|
+
*/
|
|
62
|
+
export class FrameEditSession {
|
|
63
|
+
snapshots = new Map();
|
|
64
|
+
updateFrame;
|
|
65
|
+
deleteFrame;
|
|
66
|
+
onClose;
|
|
67
|
+
#closed = false;
|
|
68
|
+
constructor(entities, updateFrame, deleteFrame, onClose) {
|
|
69
|
+
this.updateFrame = updateFrame;
|
|
70
|
+
this.deleteFrame = deleteFrame;
|
|
71
|
+
this.onClose = onClose;
|
|
72
|
+
for (const entity of entities) {
|
|
73
|
+
const name = entity.get(traits.Name);
|
|
74
|
+
const editedPose = entity.get(traits.EditedPose);
|
|
75
|
+
if (!name || !editedPose)
|
|
76
|
+
continue;
|
|
77
|
+
this.snapshots.set(entity, {
|
|
78
|
+
name,
|
|
79
|
+
parent: entity.get(traits.Parent) ?? 'world',
|
|
80
|
+
editedPose: { ...editedPose },
|
|
81
|
+
geometry: captureGeometry(entity),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
get isClosed() {
|
|
86
|
+
return this.#closed;
|
|
87
|
+
}
|
|
88
|
+
owns(entity) {
|
|
89
|
+
return entity !== undefined && !this.#closed && this.snapshots.has(entity);
|
|
90
|
+
}
|
|
91
|
+
stagePose = (entity, pose) => {
|
|
92
|
+
const snap = this.snapshots.get(entity);
|
|
93
|
+
if (!snap || this.#closed)
|
|
94
|
+
return;
|
|
95
|
+
const current = entity.get(traits.EditedPose);
|
|
96
|
+
if (!current)
|
|
97
|
+
return;
|
|
98
|
+
const next = { ...current, ...pose };
|
|
99
|
+
entity.set(traits.EditedPose, next);
|
|
100
|
+
this.updateFrame(snap.name, entity.get(traits.Parent) ?? 'world', next, liveGeometry(entity));
|
|
101
|
+
};
|
|
102
|
+
stageGeometry = (entity, geometry) => {
|
|
103
|
+
const snap = this.snapshots.get(entity);
|
|
104
|
+
if (!snap || this.#closed || !geometry)
|
|
105
|
+
return;
|
|
106
|
+
if (geometry.type === 'none') {
|
|
107
|
+
entity.remove(traits.Box, traits.Sphere, traits.Capsule);
|
|
108
|
+
}
|
|
109
|
+
else if (geometry.type === 'box') {
|
|
110
|
+
const data = { x: geometry.x, y: geometry.y, z: geometry.z };
|
|
111
|
+
restoreGeometryTrait(entity, { type: 'box', box: data });
|
|
112
|
+
}
|
|
113
|
+
else if (geometry.type === 'sphere') {
|
|
114
|
+
restoreGeometryTrait(entity, { type: 'sphere', sphere: { r: geometry.r } });
|
|
115
|
+
}
|
|
116
|
+
else if (geometry.type === 'capsule') {
|
|
117
|
+
restoreGeometryTrait(entity, { type: 'capsule', capsule: { r: geometry.r, l: geometry.l } });
|
|
118
|
+
}
|
|
119
|
+
const editedPose = entity.get(traits.EditedPose);
|
|
120
|
+
if (editedPose) {
|
|
121
|
+
this.updateFrame(snap.name, entity.get(traits.Parent) ?? 'world', editedPose, geometry);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
stageParent = (entity, parent) => {
|
|
125
|
+
const snap = this.snapshots.get(entity);
|
|
126
|
+
if (!snap || this.#closed)
|
|
127
|
+
return;
|
|
128
|
+
traits.setParentTrait(entity, parent === 'world' ? undefined : parent);
|
|
129
|
+
const editedPose = entity.get(traits.EditedPose);
|
|
130
|
+
if (editedPose) {
|
|
131
|
+
this.updateFrame(snap.name, parent, editedPose, liveGeometry(entity));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
stageDelete = (entity) => {
|
|
135
|
+
const snap = this.snapshots.get(entity);
|
|
136
|
+
if (!snap || this.#closed)
|
|
137
|
+
return;
|
|
138
|
+
this.deleteFrame(snap.name);
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Validate and close. Returns true on success. On invalid pose data
|
|
142
|
+
* (NaN/infinite from a degenerate gizmo state), aborts and returns false.
|
|
143
|
+
*/
|
|
144
|
+
commit = () => {
|
|
145
|
+
if (this.#closed)
|
|
146
|
+
return false;
|
|
147
|
+
for (const [entity] of this.snapshots) {
|
|
148
|
+
const pose = entity.get(traits.EditedPose);
|
|
149
|
+
if (pose && !isFinitePose(pose)) {
|
|
150
|
+
this.abort();
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.#close();
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Restore each owned entity's traits to its pre-session state and re-issue
|
|
159
|
+
* an updateFrame so the dirty part config matches.
|
|
160
|
+
*/
|
|
161
|
+
abort = () => {
|
|
162
|
+
if (this.#closed)
|
|
163
|
+
return;
|
|
164
|
+
for (const [entity, snap] of this.snapshots) {
|
|
165
|
+
if (entity.isAlive()) {
|
|
166
|
+
entity.set(traits.EditedPose, snap.editedPose);
|
|
167
|
+
traits.setParentTrait(entity, snap.parent === 'world' ? undefined : snap.parent);
|
|
168
|
+
restoreGeometryTrait(entity, snap.geometry);
|
|
169
|
+
}
|
|
170
|
+
this.updateFrame(snap.name, snap.parent, snap.editedPose, snapshotToFrameGeometry(snap.geometry));
|
|
171
|
+
}
|
|
172
|
+
this.#close();
|
|
173
|
+
};
|
|
174
|
+
#close = () => {
|
|
175
|
+
this.#closed = true;
|
|
176
|
+
this.onClose();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Entity } from 'koota';
|
|
2
|
+
import { FrameEditSession } from '../editing/FrameEditSession';
|
|
3
|
+
interface FrameEditSessionContext {
|
|
4
|
+
/** The currently-active session, or undefined when no gesture is in flight. */
|
|
5
|
+
current: FrameEditSession | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Open a new session over the given entities. If a previous session is still
|
|
8
|
+
* active (e.g. selection changed mid-drag and onMouseUp never fired), it is
|
|
9
|
+
* aborted first so its snapshot is restored.
|
|
10
|
+
*/
|
|
11
|
+
begin: (entities: Entity[]) => FrameEditSession;
|
|
12
|
+
}
|
|
13
|
+
export declare const provideFrameEditSession: (partID: () => string) => void;
|
|
14
|
+
export declare const useFrameEditSession: () => FrameEditSessionContext;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { FrameEditSession } from '../editing/FrameEditSession';
|
|
3
|
+
import { usePartConfig } from './usePartConfig.svelte';
|
|
4
|
+
const key = Symbol('frame-edit-session-context');
|
|
5
|
+
export const provideFrameEditSession = (partID) => {
|
|
6
|
+
const partConfig = usePartConfig();
|
|
7
|
+
let active = $state(undefined);
|
|
8
|
+
const begin = (entities) => {
|
|
9
|
+
active?.abort();
|
|
10
|
+
const session = new FrameEditSession(entities, partConfig.updateFrame, partConfig.deleteFrame, () => {
|
|
11
|
+
if (active === session)
|
|
12
|
+
active = undefined;
|
|
13
|
+
});
|
|
14
|
+
active = session;
|
|
15
|
+
return session;
|
|
16
|
+
};
|
|
17
|
+
// Drop any in-flight session when the partID changes — its snapshots reference
|
|
18
|
+
// entities from the old world that useFrames will destroy, and aborting it
|
|
19
|
+
// after the swap would write old frame names into the new part's config.
|
|
20
|
+
let lastPartID;
|
|
21
|
+
$effect.pre(() => {
|
|
22
|
+
const id = partID();
|
|
23
|
+
if (lastPartID !== undefined && lastPartID !== id) {
|
|
24
|
+
active?.abort();
|
|
25
|
+
active = undefined;
|
|
26
|
+
}
|
|
27
|
+
lastPartID = id;
|
|
28
|
+
});
|
|
29
|
+
setContext(key, {
|
|
30
|
+
get current() {
|
|
31
|
+
return active;
|
|
32
|
+
},
|
|
33
|
+
begin,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
export const useFrameEditSession = () => getContext(key);
|
|
@@ -7,6 +7,7 @@ import { traits, useWorld } from '../ecs';
|
|
|
7
7
|
import { createPose } from '../transform';
|
|
8
8
|
import { useConfigFrames } from './useConfigFrames.svelte';
|
|
9
9
|
import { useEnvironment } from './useEnvironment.svelte';
|
|
10
|
+
import { useFrameEditSession } from './useFrameEditSession.svelte';
|
|
10
11
|
import { useLogs } from './useLogs.svelte';
|
|
11
12
|
import { usePartConfig } from './usePartConfig.svelte';
|
|
12
13
|
import { useResourceByName } from './useResourceByName.svelte';
|
|
@@ -14,6 +15,7 @@ const key = Symbol('frames-context');
|
|
|
14
15
|
export const provideFrames = (partID) => {
|
|
15
16
|
const configFrames = useConfigFrames();
|
|
16
17
|
const partConfig = usePartConfig();
|
|
18
|
+
const editSession = useFrameEditSession();
|
|
17
19
|
const environment = useEnvironment();
|
|
18
20
|
const world = useWorld();
|
|
19
21
|
const resourceByName = useResourceByName();
|
|
@@ -23,6 +25,16 @@ export const provideFrames = (partID) => {
|
|
|
23
25
|
const logs = useLogs();
|
|
24
26
|
const pendingSaveKey = $derived(`viam-pending-save-revision:${partID()}`);
|
|
25
27
|
let didRecentlyEdit = $state(false);
|
|
28
|
+
let lastPartID;
|
|
29
|
+
$effect.pre(() => {
|
|
30
|
+
const id = partID();
|
|
31
|
+
if (lastPartID !== undefined && lastPartID !== id) {
|
|
32
|
+
// Stale across parts: keeps the configFrames-priority merge branch
|
|
33
|
+
// active when switching to a new part that hasn't been edited.
|
|
34
|
+
didRecentlyEdit = false;
|
|
35
|
+
}
|
|
36
|
+
lastPartID = id;
|
|
37
|
+
});
|
|
26
38
|
const isEditMode = $derived(environment.current.viewerMode === 'edit');
|
|
27
39
|
const query = createRobotQuery(client, 'frameSystemConfig', () => ({
|
|
28
40
|
refetchOnWindowFocus: false,
|
|
@@ -73,7 +85,7 @@ export const provideFrames = (partID) => {
|
|
|
73
85
|
});
|
|
74
86
|
const current = $derived(Object.values(frames));
|
|
75
87
|
const entities = new Map();
|
|
76
|
-
$effect
|
|
88
|
+
$effect(() => {
|
|
77
89
|
if (revision) {
|
|
78
90
|
untrack(() => query.refetch());
|
|
79
91
|
}
|
|
@@ -145,6 +157,13 @@ export const provideFrames = (partID) => {
|
|
|
145
157
|
subtypeToColor(currentComponentSubtypeByName[frame.referenceFrame]);
|
|
146
158
|
const existing = entities.get(entityKey);
|
|
147
159
|
if (existing) {
|
|
160
|
+
// Active edit session owns the entity's traits for the duration of
|
|
161
|
+
// the user's gesture. Skip the entire re-sync — re-setting Parent
|
|
162
|
+
// would re-evaluate the <Portal> id and re-mount the group,
|
|
163
|
+
// detaching the gizmo's drag target mid-stroke.
|
|
164
|
+
if (editSession.current?.owns(existing)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
148
167
|
traits.setParentTrait(existing, parent);
|
|
149
168
|
if (color) {
|
|
150
169
|
existing.set(traits.Color, color);
|
|
@@ -153,14 +172,30 @@ export const provideFrames = (partID) => {
|
|
|
153
172
|
existing.set(traits.Center, center);
|
|
154
173
|
}
|
|
155
174
|
traits.updateGeometryTrait(existing, frame.physicalObject);
|
|
156
|
-
|
|
175
|
+
if (!isEditMode && !partConfig.hasPendingSave) {
|
|
176
|
+
existing.set(traits.Pose, pose);
|
|
177
|
+
}
|
|
178
|
+
if (!existing.has(traits.LivePose)) {
|
|
179
|
+
existing.add(traits.LivePose(pose));
|
|
180
|
+
}
|
|
181
|
+
// Skip the EditedPose overwrite while in edit mode. The merged
|
|
182
|
+
// `frames` source can differ from query.data once didRecentlyEdit
|
|
183
|
+
// flips (fragment overrides, round-trip drift), and writing those
|
|
184
|
+
// values would shift entities whose parents the user is portaling
|
|
185
|
+
// into — the gizmo's drag target moves underneath it. Once we're
|
|
186
|
+
// back in monitor mode, the next sync resumes the overwrite.
|
|
187
|
+
if (!isEditMode) {
|
|
188
|
+
existing.set(traits.EditedPose, pose);
|
|
189
|
+
}
|
|
157
190
|
continue;
|
|
158
191
|
}
|
|
159
192
|
const entityTraits = [
|
|
160
193
|
traits.Name(name),
|
|
161
194
|
traits.Pose(pose),
|
|
162
195
|
traits.EditedPose(pose),
|
|
196
|
+
traits.LivePose(pose),
|
|
163
197
|
traits.FramesAPI,
|
|
198
|
+
traits.Transformable,
|
|
164
199
|
traits.ShowAxesHelper,
|
|
165
200
|
...traits.getParentTrait(parent),
|
|
166
201
|
];
|
|
@@ -273,7 +273,17 @@ const useStandalonePartConfig = (partID) => {
|
|
|
273
273
|
}
|
|
274
274
|
return results;
|
|
275
275
|
});
|
|
276
|
+
let lastPartID;
|
|
276
277
|
$effect.pre(() => {
|
|
278
|
+
const id = partID();
|
|
279
|
+
if (lastPartID !== undefined && lastPartID !== id) {
|
|
280
|
+
// Part changed: drop any in-memory edits/pending-save state from the
|
|
281
|
+
// previous part. `current` is left for the existing sync below to
|
|
282
|
+
// repopulate once the new part's networkPartConfig arrives.
|
|
283
|
+
isDirty = false;
|
|
284
|
+
hasPendingSave = false;
|
|
285
|
+
}
|
|
286
|
+
lastPartID = id;
|
|
277
287
|
if (!networkPartConfig || isDirty) {
|
|
278
288
|
return;
|
|
279
289
|
}
|
|
@@ -9,9 +9,8 @@ export interface Settings {
|
|
|
9
9
|
};
|
|
10
10
|
disabledCameras: Record<string, boolean>;
|
|
11
11
|
disabledVisionServices: Record<string, boolean>;
|
|
12
|
-
transforming: boolean;
|
|
13
12
|
snapping: boolean;
|
|
14
|
-
transformMode: 'translate' | 'rotate' | 'scale';
|
|
13
|
+
transformMode: 'none' | 'translate' | 'rotate' | 'scale';
|
|
15
14
|
grid: boolean;
|
|
16
15
|
gridCellSize: number;
|
|
17
16
|
gridSectionSize: number;
|
package/dist/transform.js
CHANGED
|
@@ -105,3 +105,16 @@ export const matrixToPose = (matrix) => {
|
|
|
105
105
|
pose.theta = MathUtils.radToDeg(ov.th);
|
|
106
106
|
return pose;
|
|
107
107
|
};
|
|
108
|
+
export const composeRenderedPose = (livePose, baselinePose, editedPose) => matrixToPose(poseToMatrix(livePose)
|
|
109
|
+
.multiply(poseToMatrix(baselinePose).invert())
|
|
110
|
+
.multiply(poseToMatrix(editedPose)));
|
|
111
|
+
export const composeEditedPoseForRenderedPose = (baselinePose, livePose, renderedPose) => matrixToPose(poseToMatrix(baselinePose)
|
|
112
|
+
.multiply(poseToMatrix(livePose).invert())
|
|
113
|
+
.multiply(poseToMatrix(renderedPose)));
|
|
114
|
+
export const isFinitePose = (pose) => Number.isFinite(pose.x) &&
|
|
115
|
+
Number.isFinite(pose.y) &&
|
|
116
|
+
Number.isFinite(pose.z) &&
|
|
117
|
+
Number.isFinite(pose.oX) &&
|
|
118
|
+
Number.isFinite(pose.oY) &&
|
|
119
|
+
Number.isFinite(pose.oZ) &&
|
|
120
|
+
Number.isFinite(pose.theta);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viamrobotics/motion-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Motion visualization with Viam",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@testing-library/jest-dom": "6.8.0",
|
|
26
26
|
"@testing-library/svelte": "5.2.8",
|
|
27
27
|
"@testing-library/user-event": "^14.6.1",
|
|
28
|
-
"@threlte/core": "8.5.
|
|
29
|
-
"@threlte/extras": "9.
|
|
28
|
+
"@threlte/core": "8.5.11",
|
|
29
|
+
"@threlte/extras": "9.15.0",
|
|
30
30
|
"@threlte/rapier": "3.4.1",
|
|
31
31
|
"@threlte/xr": "1.5.2",
|
|
32
32
|
"@types/bun": "1.2.21",
|