-
Notifications
You must be signed in to change notification settings - Fork 1
app-16292: add Gizmos plugin #738
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 114 commits
faf59c0
9e637c6
02bc0d0
bf49747
9bd8689
030b969
585e9c2
50cffd7
77eed59
c18c0e7
f02fb53
6854ba0
0d33dea
dfef837
4b68aae
c00b48a
2bcdba2
b18c167
fd1cd53
b890b95
82d5076
51809f3
3741933
fea6014
8d67f43
db5c541
83b98a6
3d6bf32
e1b0cbe
e25aa16
f8c93fb
e08aa03
b51e16b
54eeeb7
8868ae4
d58bb09
33bf972
c9b7864
c4f4008
1d37aea
ac4ad94
3c28edb
a816a45
b11891e
6116b7d
300e4d9
df6af8a
451c04e
1c294c8
1bc584f
8e8bc5e
12b8101
d3aeab2
259e9b9
ddd050a
e47694e
9ad63d7
5061ec9
c6d4288
7d8b902
2450370
1aeff3b
94f3b26
406608a
081f852
ad9d95d
058c077
30ff3e0
f2b8a19
74456a0
4c7ab2f
d076b3f
b7b7d7c
168cd3e
473af2e
873a67b
72a6fce
a92007c
2d7c9c1
d5a080e
17e814f
76b80ef
053d6e1
51d9b2b
d3f4b78
70a5b6f
3fa8e62
22b2dc9
f552dd3
5626401
ce8a85c
156feed
299bed5
2cdca10
55f5596
7900e97
fb7f589
7d48588
5ccbd2b
16b6268
84b0156
b85557c
e715f79
b92ec38
fec6d11
6bc1fd6
43ab2e9
f376b4d
0915f65
d509e21
425ba30
31a8473
b4be1e2
4ab25a7
b963b56
744e462
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@viamrobotics/motion-tools': minor | ||
| --- | ||
|
|
||
| Add `Gizmos` plugin for placing editable scene aids (coordinate systems, reference planes, reference geometries, polylines, arrows, vertex normals, and surface normals) with a `useGizmos` hook and `gizmoTraits` namespace |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,11 +21,20 @@ | |
|
|
||
| {@render trigger(api.getTriggerProps(), { isOpen: api.open })} | ||
|
|
||
| <!-- | ||
| The menu is portalled to <body>, so it must out-stack scene-space entity labels and | ||
| panels. zag drives the positioner's z-index from `var(--z-index)`, which it copies from | ||
| the *content* element's computed z-index. So the z-index must live on the content, not | ||
| the positioner. | ||
| --> | ||
| <div | ||
| use:portal={{ disabled: !api.portalled }} | ||
| {...api.getPositionerProps()} | ||
| > | ||
| <div {...api.getContentProps()}> | ||
| <div | ||
| class="z-max" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need z-max? Can we use a less aggressive value? (I think we use around z-5) for other floating UI
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was hitting some weirdness but I can lower it. though IMO I think we may want to introduce some semantic z-layering classes to reduce having to go search the codebase for every z-x usage and determine how to layer. like z-popover, z-panel, z-label or something.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep, all for this |
||
| {...api.getContentProps()} | ||
| > | ||
| {@render children({ close })} | ||
| </div> | ||
| </div> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| <script lang="ts"> | ||
| import type { Vector3Tuple } from 'three' | ||
|
|
||
| import { HTML } from '@threlte/extras' | ||
| import { Check, Plus, Undo2, X } from 'lucide-svelte' | ||
|
|
||
| interface Props { | ||
| position: Vector3Tuple | ||
| onConfirm: () => void | ||
| onCancel: () => void | ||
| onAddNext?: () => void | ||
| onUndo?: () => void | ||
| } | ||
|
|
||
| const { position, onConfirm, onCancel, onAddNext, onUndo }: Props = $props() | ||
|
|
||
| const stopPointerBubble = (el: HTMLElement) => { | ||
| const stop = (event: PointerEvent) => event.stopPropagation() | ||
| el.addEventListener('pointerdown', stop) | ||
| el.addEventListener('pointerup', stop) | ||
| el.addEventListener('pointermove', stop) | ||
| return { | ||
| destroy() { | ||
| el.removeEventListener('pointerdown', stop) | ||
| el.removeEventListener('pointerup', stop) | ||
| el.removeEventListener('pointermove', stop) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| const buttonClass = | ||
| 'hover:bg-light flex min-w-9 flex-col items-center justify-center gap-0.5 rounded px-1.5 py-1 focus:outline-none focus-visible:ring-2' | ||
| const hotkeyClass = 'font-mono text-[10px] leading-none opacity-60' | ||
| </script> | ||
|
|
||
| <HTML | ||
| center | ||
| {position} | ||
| zIndexRange={[100, 0]} | ||
| > | ||
| <div | ||
| class="border-medium pointer-events-auto flex -translate-y-10 gap-0.5 rounded border bg-white p-0.5 shadow-md" | ||
| use:stopPointerBubble | ||
| > | ||
| {#if onUndo} | ||
| <button | ||
| class={[buttonClass, 'text-blue-600 focus-visible:ring-blue-500']} | ||
| type="button" | ||
| title="Undo last (⌫)" | ||
| onclick={onUndo} | ||
| > | ||
| <Undo2 | ||
| class="size-4" | ||
| aria-hidden="true" | ||
| /> | ||
| <span | ||
| class={hotkeyClass} | ||
| aria-hidden="true">⌫</span | ||
| > | ||
| </button> | ||
| {/if} | ||
| <button | ||
| class={[buttonClass, 'text-red-600 focus-visible:ring-red-500']} | ||
| type="button" | ||
| title="Cancel (esc)" | ||
| onclick={onCancel} | ||
| > | ||
| <X | ||
| class="size-4" | ||
| aria-hidden="true" | ||
| /> | ||
| <span | ||
| class={hotkeyClass} | ||
| aria-hidden="true">Esc</span | ||
| > | ||
| </button> | ||
| {#if onAddNext} | ||
| <button | ||
| class={[buttonClass, 'text-blue-600 focus-visible:ring-blue-500']} | ||
| type="button" | ||
| title="Commit and add another (space)" | ||
| onclick={onAddNext} | ||
| > | ||
| <Plus | ||
| class="size-4" | ||
| aria-hidden="true" | ||
| /> | ||
| <span | ||
| class={hotkeyClass} | ||
| aria-hidden="true">Space</span | ||
| > | ||
| </button> | ||
| {/if} | ||
| <button | ||
| class={[buttonClass, 'text-green-600 focus-visible:ring-green-500']} | ||
| type="button" | ||
| title="Confirm and exit (↵)" | ||
| onclick={onConfirm} | ||
| > | ||
| <Check | ||
| class="size-4" | ||
| aria-hidden="true" | ||
| /> | ||
| <span | ||
| class={hotkeyClass} | ||
| aria-hidden="true">↵</span | ||
| > | ||
| </button> | ||
| </div> | ||
| </HTML> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| <script module> | ||
| import { Color } from 'three' | ||
|
|
||
| import { ARROW_LENGTH, createArrowGeometry } from '$lib/three/arrow' | ||
|
|
||
| const colorUtil = new Color() | ||
| const sharedArrowGeometry = createArrowGeometry() | ||
| </script> | ||
|
|
||
| <script lang="ts"> | ||
| import type { Entity } from 'koota' | ||
| import type { Snippet } from 'svelte' | ||
|
|
||
| import { T, useThrelte } from '@threlte/core' | ||
| import { Group, Mesh } from 'three' | ||
|
|
||
| import { colors } from '$lib/color' | ||
| import AxesHelper from '$lib/components/AxesHelper.svelte' | ||
| import { useEntityEvents } from '$lib/components/Entities/hooks/useEntityEvents.svelte' | ||
| import { traits, useTrait } from '$lib/ecs' | ||
|
|
||
| interface Props { | ||
| entity: Entity | ||
| children?: Snippet | ||
| } | ||
|
|
||
| const { entity, children }: Props = $props() | ||
|
|
||
| const { invalidate } = useThrelte() | ||
| const name = useTrait(() => entity, traits.Name) | ||
| const worldMatrix = useTrait(() => entity, traits.WorldMatrix) | ||
| const entityColor = useTrait(() => entity, traits.Color) | ||
| const opacity = useTrait(() => entity, traits.Opacity) | ||
| const showAxesHelper = useTrait(() => entity, traits.ShowAxesHelper) | ||
| const invisible = useTrait(() => entity, traits.InheritedInvisible) | ||
| const events = useEntityEvents(() => entity) | ||
|
|
||
| const group = new Group() | ||
| group.matrixAutoUpdate = false | ||
|
|
||
| const mesh = new Mesh() | ||
| mesh.position.y = -ARROW_LENGTH | ||
|
|
||
| const color = $derived.by(() => { | ||
| if (entityColor.current) { | ||
| return `#${colorUtil | ||
| .setRGB(entityColor.current.r, entityColor.current.g, entityColor.current.b) | ||
| .getHexString()}` | ||
| } | ||
|
|
||
| return colors.default | ||
| }) | ||
|
|
||
| const currentOpacity = $derived(opacity.current ?? 1) | ||
|
|
||
| $effect.pre(() => { | ||
| if (!worldMatrix.current) return | ||
|
|
||
| group.matrix.copy(worldMatrix.current) | ||
| group.matrix.decompose(group.position, group.quaternion, group.scale) | ||
| group.updateMatrixWorld() | ||
| invalidate() | ||
| }) | ||
| </script> | ||
|
|
||
| <T | ||
| is={group} | ||
| visible={invisible.current !== true} | ||
| > | ||
| <T | ||
| is={mesh} | ||
| name={entity} | ||
| userData.name={name} | ||
| {...events} | ||
| > | ||
| <T | ||
| is={sharedArrowGeometry} | ||
| dispose={false} | ||
| /> | ||
| <T.MeshToonMaterial | ||
| {color} | ||
| transparent={currentOpacity < 1} | ||
| depthWrite={currentOpacity === 1} | ||
| opacity={currentOpacity} | ||
| /> | ||
| </T> | ||
|
|
||
| {#if showAxesHelper.current} | ||
| <AxesHelper | ||
| name={entity} | ||
| width={3} | ||
| length={0.1} | ||
| /> | ||
| {/if} | ||
|
|
||
| {@render children?.()} | ||
| </T> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| <script lang="ts"> | ||
| import { Portal } from '@threlte/extras' | ||
| import { Shapes } from 'lucide-svelte' | ||
|
|
||
| import AxesHelperDetails from '$lib/components/overlay/details/AxesHelperDetails.svelte' | ||
| import ColorDetails from '$lib/components/overlay/details/ColorDetails.svelte' | ||
| import GeometryDetails from '$lib/components/overlay/details/GeometryDetails.svelte' | ||
| import LineDetails from '$lib/components/overlay/details/LineDetails/LineDetails.svelte' | ||
| import OpacityDetails from '$lib/components/overlay/details/OpacityDetails.svelte' | ||
| import PoseDetails from '$lib/components/overlay/details/PoseDetails.svelte' | ||
| import { hierarchy, traits, useQuery, useTag, useTrait } from '$lib/ecs' | ||
|
|
||
| import PlaneDetails from './PlaneDetails.svelte' | ||
| import { Gizmo, GizmoArrow, Plane } from './traits' | ||
|
|
||
| const selected = useQuery(traits.Selected) | ||
| const entity = $derived(selected.current[0]) | ||
| const gizmo = useTag(() => entity, Gizmo) | ||
|
|
||
| const plane = useTrait(() => entity, Plane) | ||
| const box = useTrait(() => entity, traits.Box) | ||
| const sphere = useTrait(() => entity, traits.Sphere) | ||
| const capsule = useTrait(() => entity, traits.Capsule) | ||
| const linePositions = useTrait(() => entity, traits.LinePositions) | ||
| const gizmoArrow = useTag(() => entity, GizmoArrow) | ||
|
|
||
| const isReferencePlane = $derived(Boolean(plane.current)) | ||
| const isReferenceGeometry = $derived(Boolean(box.current || sphere.current || capsule.current)) | ||
| const isLine = $derived(Boolean(linePositions.current)) | ||
| const isArrow = $derived(Boolean(gizmoArrow.current)) | ||
|
|
||
| const entities = useQuery(traits.Name) | ||
|
|
||
| const gizmoName = $derived.by(() => { | ||
| if (!entity) return | ||
| return entity.get(traits.Name) | ||
| }) | ||
|
|
||
| const parentOptions = $derived.by(() => { | ||
| const opts = [{ value: 'world', text: 'world' }] | ||
| for (const candidate of entities.current) { | ||
| const name = candidate.get(traits.Name) | ||
| if (!name || name === 'world' || name === gizmoName) continue | ||
| opts.push({ value: name, text: name }) | ||
| } | ||
|
|
||
| return opts | ||
| }) | ||
| </script> | ||
|
|
||
| {#if gizmo.current} | ||
| <Portal id="details-header-icon"> | ||
| <span | ||
| class="text-info-dark px-1" | ||
| aria-label="gizmo" | ||
| > | ||
| <Shapes size="16" /> | ||
| </span> | ||
| </Portal> | ||
| {/if} | ||
|
|
||
| {#if gizmo.current && entity} | ||
| <Portal id="details-extensions"> | ||
| <div class="flex flex-col gap-2.5 text-xs"> | ||
| <PoseDetails | ||
| {entity} | ||
| {parentOptions} | ||
| onPoseChange={(patch) => traits.writeMatrix(entity, patch)} | ||
| onParentChange={(next) => hierarchy.setParent(entity, next)} | ||
| /> | ||
| {#if isReferencePlane} | ||
| <PlaneDetails {entity} /> | ||
| <ColorDetails {entity} /> | ||
| <OpacityDetails {entity} /> | ||
| <AxesHelperDetails {entity} /> | ||
| {/if} | ||
| {#if isReferenceGeometry} | ||
| <GeometryDetails {entity} /> | ||
| <ColorDetails {entity} /> | ||
| <OpacityDetails {entity} /> | ||
| <AxesHelperDetails {entity} /> | ||
| {/if} | ||
| {#if isLine} | ||
| <LineDetails {entity} /> | ||
| <OpacityDetails {entity} /> | ||
| {/if} | ||
| {#if isArrow} | ||
| <ColorDetails {entity} /> | ||
| <OpacityDetails {entity} /> | ||
| <AxesHelperDetails {entity} /> | ||
| {/if} | ||
| </div> | ||
| </Portal> | ||
| {/if} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <script lang="ts"> | ||
| import Label from '$lib/components/Entities/Label.svelte' | ||
| import { useQuery } from '$lib/ecs' | ||
|
|
||
| import GizmoArrow from './GizmoArrow.svelte' | ||
| import GizmoNormals from './GizmoNormals.svelte' | ||
| import GizmoPlane from './GizmoPlane.svelte' | ||
| import GizmoPolylineMeasure from './GizmoPolylineMeasure.svelte' | ||
| import * as gizmoTraits from './traits' | ||
|
|
||
| const planeGizmos = useQuery(gizmoTraits.Plane) | ||
| const arrowGizmos = useQuery(gizmoTraits.GizmoArrow) | ||
| const polylineMeasures = useQuery(gizmoTraits.PolylineMeasure) | ||
| const vertexNormals = useQuery(gizmoTraits.VertexNormals) | ||
| const surfaceNormals = useQuery(gizmoTraits.SurfaceNormals) | ||
| </script> | ||
|
|
||
| {#each planeGizmos.current as entity (entity)} | ||
| <GizmoPlane {entity}> | ||
| <Label {entity} /> | ||
| </GizmoPlane> | ||
| {/each} | ||
|
|
||
| {#each arrowGizmos.current as entity (entity)} | ||
| <GizmoArrow {entity}> | ||
| <Label {entity} /> | ||
| </GizmoArrow> | ||
| {/each} | ||
|
|
||
| {#each polylineMeasures.current as entity (entity)} | ||
| <GizmoPolylineMeasure {entity} /> | ||
| {/each} | ||
|
|
||
| {#each vertexNormals.current as entity (entity)} | ||
| <GizmoNormals | ||
| {entity} | ||
| kind="vertex" | ||
| /> | ||
| {/each} | ||
|
|
||
| {#each surfaceNormals.current as entity (entity)} | ||
| <GizmoNormals | ||
| {entity} | ||
| kind="surface" | ||
| /> | ||
| {/each} |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't make sense for continuously rendered scenes either.
$effect.preruns synchronously and immediately.$effectruns in a batched manner in a microtask. But the guaranteed order of operations for JS per frame is:Microtasks always run before
requestAnimationFrame. Therefore even in continuously running apps$effect()is fine. The only time it's not fine is when it's writing Three.js props that other effects will potentially read from. Then $effect.pre is more necessary to get ahead of the other reads. But this is rare and sort of dangerous.