@gadxr/ui 0.0.0-stage → 0.1.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Avatar.svelte +136 -0
  3. package/dist/Avatar.svelte.d.ts +13 -0
  4. package/dist/Button.svelte +29 -0
  5. package/dist/Button.svelte.d.ts +4 -0
  6. package/dist/Button2D.svelte +77 -0
  7. package/dist/Button2D.svelte.d.ts +4 -0
  8. package/dist/Button3D.svelte +98 -0
  9. package/dist/Button3D.svelte.d.ts +4 -0
  10. package/dist/ButtonGroup.svelte +59 -0
  11. package/dist/ButtonGroup.svelte.d.ts +17 -0
  12. package/dist/Checkbox.svelte +209 -0
  13. package/dist/Checkbox.svelte.d.ts +14 -0
  14. package/dist/DimensionScope.svelte +7 -0
  15. package/dist/DimensionScope.svelte.d.ts +9 -0
  16. package/dist/Input.svelte +293 -0
  17. package/dist/Input.svelte.d.ts +23 -0
  18. package/dist/Key3D.svelte +67 -0
  19. package/dist/Key3D.svelte.d.ts +13 -0
  20. package/dist/Keyboard.svelte +167 -0
  21. package/dist/Keyboard.svelte.d.ts +9 -0
  22. package/dist/Menu.svelte +193 -0
  23. package/dist/Menu.svelte.d.ts +23 -0
  24. package/dist/Modal.svelte +103 -0
  25. package/dist/Modal.svelte.d.ts +16 -0
  26. package/dist/Panel.svelte +132 -0
  27. package/dist/Panel.svelte.d.ts +19 -0
  28. package/dist/Pressable3D.svelte +82 -0
  29. package/dist/Pressable3D.svelte.d.ts +28 -0
  30. package/dist/ProgressBar.svelte +153 -0
  31. package/dist/ProgressBar.svelte.d.ts +16 -0
  32. package/dist/RadialMenu.svelte +322 -0
  33. package/dist/RadialMenu.svelte.d.ts +28 -0
  34. package/dist/Rule3D.svelte +17 -0
  35. package/dist/Rule3D.svelte.d.ts +9 -0
  36. package/dist/Scene3D.svelte +24 -0
  37. package/dist/Scene3D.svelte.d.ts +11 -0
  38. package/dist/Slider.svelte +209 -0
  39. package/dist/Slider.svelte.d.ts +17 -0
  40. package/dist/Space3D.svelte +6 -0
  41. package/dist/Space3D.svelte.d.ts +7 -0
  42. package/dist/Spinner.svelte +64 -0
  43. package/dist/Spinner.svelte.d.ts +10 -0
  44. package/dist/Stack.svelte +41 -0
  45. package/dist/Stack.svelte.d.ts +15 -0
  46. package/dist/StackScope.svelte +7 -0
  47. package/dist/StackScope.svelte.d.ts +9 -0
  48. package/dist/Surface.svelte +152 -0
  49. package/dist/Surface.svelte.d.ts +30 -0
  50. package/dist/Tab.svelte +40 -0
  51. package/dist/Tab.svelte.d.ts +10 -0
  52. package/dist/Tabs.svelte +145 -0
  53. package/dist/Tabs.svelte.d.ts +13 -0
  54. package/dist/Tag.svelte +71 -0
  55. package/dist/Tag.svelte.d.ts +12 -0
  56. package/dist/Text.svelte +75 -0
  57. package/dist/Text.svelte.d.ts +4 -0
  58. package/dist/TextLine3D.svelte +84 -0
  59. package/dist/TextLine3D.svelte.d.ts +19 -0
  60. package/dist/ThemeProvider.svelte +7 -0
  61. package/dist/ThemeProvider.svelte.d.ts +9 -0
  62. package/dist/Toaster.svelte +104 -0
  63. package/dist/Toaster.svelte.d.ts +11 -0
  64. package/dist/Tooltip.svelte +174 -0
  65. package/dist/Tooltip.svelte.d.ts +14 -0
  66. package/dist/aframe/frame.d.ts +1 -0
  67. package/dist/aframe/frame.js +37 -0
  68. package/dist/aframe/index.d.ts +1 -0
  69. package/dist/aframe/index.js +19 -0
  70. package/dist/aframe/measure.d.ts +3 -0
  71. package/dist/aframe/measure.js +42 -0
  72. package/dist/aframe/placement.d.ts +88 -0
  73. package/dist/aframe/placement.js +163 -0
  74. package/dist/aframe/rounded.d.ts +61 -0
  75. package/dist/aframe/rounded.js +121 -0
  76. package/dist/aframe/scene.d.ts +9 -0
  77. package/dist/aframe/scene.js +101 -0
  78. package/dist/assets/Geist-OFL.txt +93 -0
  79. package/dist/assets/Geist-Regular.ttf +0 -0
  80. package/dist/button-style.d.ts +22 -0
  81. package/dist/button-style.js +34 -0
  82. package/dist/component-style.d.ts +79 -0
  83. package/dist/component-style.js +129 -0
  84. package/dist/context.d.ts +16 -0
  85. package/dist/context.js +26 -0
  86. package/dist/fonts.css +7 -0
  87. package/dist/fonts.d.ts +2 -0
  88. package/dist/fonts.js +7 -0
  89. package/dist/index.d.ts +40 -0
  90. package/dist/index.js +40 -0
  91. package/dist/keyboard-layout.d.ts +28 -0
  92. package/dist/keyboard-layout.js +77 -0
  93. package/dist/layout-math.d.ts +20 -0
  94. package/dist/layout-math.js +22 -0
  95. package/dist/layout.svelte.d.ts +17 -0
  96. package/dist/layout.svelte.js +58 -0
  97. package/dist/radial-math.d.ts +33 -0
  98. package/dist/radial-math.js +90 -0
  99. package/dist/tabs-context.d.ts +13 -0
  100. package/dist/tabs-context.js +6 -0
  101. package/dist/text-edit.d.ts +46 -0
  102. package/dist/text-edit.js +133 -0
  103. package/dist/text-focus.svelte.d.ts +24 -0
  104. package/dist/text-focus.svelte.js +102 -0
  105. package/dist/theme.d.ts +335 -0
  106. package/dist/theme.js +312 -0
  107. package/dist/toast.svelte.d.ts +24 -0
  108. package/dist/toast.svelte.js +30 -0
  109. package/dist/types.d.ts +25 -0
  110. package/dist/types.js +1 -0
  111. package/dist/xr.svelte.d.ts +9 -0
  112. package/dist/xr.svelte.js +24 -0
  113. package/package.json +44 -3
  114. package/README.md +0 -3
@@ -0,0 +1,174 @@
1
+ <script lang="ts">import { ensure3d } from "./aframe";
2
+ import { watchTextSize } from "./aframe/measure";
3
+ import { tooltipOffset } from "./component-style";
4
+ import { getDimension, getTheme } from "./context";
5
+ import { font3d } from "./fonts";
6
+ import { createStack, setStack, useSlot } from "./layout.svelte";
7
+ const theme = getTheme();
8
+ let { content, children, placement = "top", open = $bindable(false), delay = theme().tooltip.delay, dimension, style: userStyle = "", ...rest } = $props();
9
+ const t = $derived(theme());
10
+ const tk = $derived(t.tooltip);
11
+ const c = $derived(t.colors.tooltip);
12
+ const font = $derived(t.typography[tk.typography]);
13
+ const contextDimension = getDimension();
14
+ // svelte-ignore state_referenced_locally
15
+ const is3d = (dimension ?? contextDimension) === "3d";
16
+ const id = `ui-tooltip-${Math.random().toString(36).slice(2, 9)}`;
17
+ let timer;
18
+ function show(now = false) {
19
+ clearTimeout(timer);
20
+ if (now || delay <= 0) open = true;
21
+ else timer = setTimeout(() => open = true, delay);
22
+ }
23
+ function hide() {
24
+ clearTimeout(timer);
25
+ open = false;
26
+ }
27
+ $effect(() => () => clearTimeout(timer));
28
+ $effect(() => {
29
+ if (!open) return;
30
+ const onkey = (event) => event.key === "Escape" && hide();
31
+ window.addEventListener("keydown", onkey);
32
+ return () => window.removeEventListener("keydown", onkey);
33
+ });
34
+ // ---- 3D ----
35
+ const m = (px) => px * t.space3d.metersPerPx;
36
+ if (is3d) ensure3d();
37
+ // The trigger lays out in a stack of its own, which tells us its size; this takes its
38
+ // place in the parent's layout like the trigger would have.
39
+ const stack = is3d ? createStack(() => ({
40
+ direction: "row",
41
+ gap: 0,
42
+ align: "center"
43
+ })) : undefined;
44
+ const slot = is3d ? useSlot(() => stack.size) : () => ({
45
+ x: 0,
46
+ y: 0
47
+ });
48
+ setStack(stack);
49
+ let textEl = $state();
50
+ let textSize = $state({
51
+ width: 0,
52
+ height: 0
53
+ });
54
+ $effect(() => {
55
+ if (textEl) return watchTextSize(textEl, (width, height) => textSize = {
56
+ width,
57
+ height
58
+ });
59
+ });
60
+ const bubble = $derived({
61
+ width: textSize.width + 2 * m(tk.paddingX),
62
+ height: textSize.height + 2 * m(tk.paddingY)
63
+ });
64
+ const at = $derived(tooltipOffset(placement, stack?.size ?? {
65
+ width: 0,
66
+ height: 0
67
+ }, bubble, m(tk.gap)));
68
+ // In front of the trigger (and a hovered, lifted button), so nothing covers it.
69
+ const lift = $derived(t.space3d.depth + t.space3d.pressDepth + .01);
70
+ const bubbleDepth = .008;
71
+ </script>
72
+
73
+ {#if is3d}
74
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
75
+ <a-entity {...rest} onmouseenter={() => show()} onmouseleave={hide}>
76
+ <a-entity position="{slot().x} {slot().y} 0">
77
+ <!-- Hover target for triggers that aren't interactive themselves (an Avatar, a Tag):
78
+ the cursor only reports .raycastable meshes. Just behind the trigger, so a
79
+ Button in front still gets its own hover and clicks. -->
80
+ <a-entity
81
+ class="raycastable"
82
+ geometry="primitive: plane; width: {stack?.size.width ?? 0}; height: {stack?.size.height ?? 0}"
83
+ material="visible: false"
84
+ position="0 0 -0.002"
85
+ ></a-entity>
86
+ {@render children()}
87
+ <!-- Always there (hidden), so its text is measured before it first shows. Not raycastable,
88
+ so it never takes the hover from its trigger. -->
89
+ <a-entity visible={open && textSize.width > 0} position="{at.x} {at.y} {lift}">
90
+ <a-entity
91
+ rounded="width: {bubble.width}; height: {bubble.height}; depth: {bubbleDepth}; radius: {Math.min(m(tk.radius), bubble.height / 2)}; color: {c.fill}"
92
+ position="{-bubble.width / 2} {-bubble.height / 2} 0"
93
+ ></a-entity>
94
+ <a-entity
95
+ bind:this={textEl}
96
+ troika-text="value: {content.replace(/;/g, ',')}; fontSize: {m(font.fontSize) * t.space3d.textScale}; lineHeight: {font.lineHeight / font.fontSize}; maxWidth: {m(tk.maxWidth)}; color: {c.text}; font: {font3d(t)}; anchor: center; baseline: center; align: center"
97
+ troika-text-material="shader: standard"
98
+ position="0 0 {bubbleDepth + 0.002}"
99
+ ></a-entity>
100
+ </a-entity>
101
+ </a-entity>
102
+ </a-entity>
103
+ {:else}
104
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
105
+ <span
106
+ class="ui-tooltip"
107
+ aria-describedby={open ? id : undefined}
108
+ onmouseenter={() => show()}
109
+ onmouseleave={hide}
110
+ onfocusin={() => show(true)}
111
+ onfocusout={hide}
112
+ style={userStyle as string}
113
+ {...rest}
114
+ >
115
+ {@render children()}
116
+ {#if open}
117
+ <span
118
+ {id}
119
+ role="tooltip"
120
+ class="bubble"
121
+ data-placement={placement}
122
+ style="--ui-fill: {c.fill}; --ui-text: {c.text}; --ui-pad: {tk.paddingY}px {tk.paddingX}px; --ui-radius: {tk.radius}px; --ui-gap: {tk.gap}px; --ui-max: {tk.maxWidth}px; --ui-font: {font.fontWeight} {font.fontSize}px/{font.lineHeight}px {t.typography.fontFamily};"
123
+ >
124
+ {content}
125
+ </span>
126
+ {/if}
127
+ </span>
128
+ {/if}
129
+
130
+ <style>
131
+ .ui-tooltip {
132
+ position: relative;
133
+ display: inline-flex;
134
+ }
135
+ .bubble {
136
+ position: absolute;
137
+ z-index: 1100;
138
+ width: max-content;
139
+ max-width: var(--ui-max);
140
+ padding: var(--ui-pad);
141
+ border-radius: var(--ui-radius);
142
+ background: var(--ui-fill);
143
+ color: var(--ui-text);
144
+ font: var(--ui-font);
145
+ text-align: center;
146
+ pointer-events: none;
147
+ animation: ui-tooltip-in 0.12s ease-out;
148
+ }
149
+ [data-placement='top'] {
150
+ bottom: calc(100% + var(--ui-gap));
151
+ left: 50%;
152
+ translate: -50% 0;
153
+ }
154
+ [data-placement='bottom'] {
155
+ top: calc(100% + var(--ui-gap));
156
+ left: 50%;
157
+ translate: -50% 0;
158
+ }
159
+ [data-placement='left'] {
160
+ right: calc(100% + var(--ui-gap));
161
+ top: 50%;
162
+ translate: 0 -50%;
163
+ }
164
+ [data-placement='right'] {
165
+ left: calc(100% + var(--ui-gap));
166
+ top: 50%;
167
+ translate: 0 -50%;
168
+ }
169
+ @keyframes ui-tooltip-in {
170
+ from {
171
+ opacity: 0;
172
+ }
173
+ }
174
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Dimension, TooltipPlacement } from './theme';
3
+ type Props = {
4
+ content: string;
5
+ children: Snippet;
6
+ placement?: TooltipPlacement;
7
+ open?: boolean;
8
+ delay?: number;
9
+ dimension?: Dimension;
10
+ [attribute: string]: unknown;
11
+ };
12
+ declare const Tooltip: import("svelte").Component<Props, {}, "open">;
13
+ type Tooltip = ReturnType<typeof Tooltip>;
14
+ export default Tooltip;
@@ -0,0 +1 @@
1
+ export declare function everyFrame(scene: any, callback: () => void): () => void;
@@ -0,0 +1,37 @@
1
+ // Runs `callback` once per rendered frame until the returned stop() is called.
2
+ // While an immersive XR session is running, the page's requestAnimationFrame is paused
3
+ // (frames come from the session instead), so this uses the session's loop then, switching
4
+ // when VR starts or ends mid-loop.
5
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
+ export function everyFrame(scene, callback) {
7
+ let stopped = false;
8
+ let id = 0;
9
+ let source = globalThis;
10
+ const loop = () => {
11
+ if (stopped)
12
+ return;
13
+ callback();
14
+ schedule();
15
+ };
16
+ const schedule = () => {
17
+ source = scene?.xrSession ?? globalThis;
18
+ id = source.requestAnimationFrame(loop);
19
+ };
20
+ // A frame queued on the page loop just before entering VR would never run (and one on
21
+ // the session loop dies with the session), so switch loops when VR starts or ends.
22
+ const reschedule = () => {
23
+ if (stopped)
24
+ return;
25
+ source.cancelAnimationFrame?.(id);
26
+ schedule();
27
+ };
28
+ schedule();
29
+ scene?.addEventListener?.('enter-vr', reschedule);
30
+ scene?.addEventListener?.('exit-vr', reschedule);
31
+ return () => {
32
+ stopped = true;
33
+ source.cancelAnimationFrame?.(id);
34
+ scene?.removeEventListener?.('enter-vr', reschedule);
35
+ scene?.removeEventListener?.('exit-vr', reschedule);
36
+ };
37
+ }
@@ -0,0 +1 @@
1
+ export declare function ensure3d(): void;
@@ -0,0 +1,19 @@
1
+ // Gets the page's A-Frame ready for the design system's 3D components.
2
+ import { registerPlacement } from './placement';
3
+ import { registerRounded } from './rounded';
4
+ import { TROIKA_SCRIPT } from './scene';
5
+ let checkedTroika = false;
6
+ // `rounded`, `ui-follow` and `ui-attach` ship with the library and are registered here. `troika-text` can't be
7
+ // bundled without pulling in a second copy of three.js / A-Frame, so it's loaded as a script:
8
+ // <Scene3D> adds it if the page didn't (otherwise load aframe-troika-text after A-Frame).
9
+ export function ensure3d() {
10
+ registerRounded();
11
+ registerPlacement();
12
+ if (checkedTroika)
13
+ return;
14
+ checkedTroika = true;
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ if (!globalThis.AFRAME.components['troika-text']) {
17
+ console.error(`gadxr ui: 3D labels need the A-Frame troika-text component. Add <script src="${TROIKA_SCRIPT}"></script> after A-Frame.`);
18
+ }
19
+ }
@@ -0,0 +1,3 @@
1
+ export declare function watchTextLayout(el: HTMLElement, onLayout: (info: any) => void): () => void;
2
+ export declare function watchTextSize(el: HTMLElement, onSize: (width: number, height: number) => void): () => void;
3
+ export declare function setTroikaValue(el: HTMLElement | undefined, value: string): void;
@@ -0,0 +1,42 @@
1
+ // Reports a troika-text entity's layout whenever its text re-syncs: its size (meters),
2
+ // or the whole textRenderInfo (caret positions etc.) for text fields.
3
+ /* eslint-disable @typescript-eslint/no-explicit-any */
4
+ export function watchTextLayout(el, onLayout) {
5
+ const entity = el;
6
+ let mesh;
7
+ const report = () => {
8
+ if (mesh?.textRenderInfo)
9
+ onLayout(mesh.textRenderInfo);
10
+ };
11
+ const attach = () => {
12
+ mesh = entity.components?.['troika-text']?.troikaTextMesh;
13
+ if (!mesh)
14
+ return false;
15
+ mesh.addEventListener('synccomplete', report);
16
+ report();
17
+ return true;
18
+ };
19
+ const onInitialized = (event) => {
20
+ if (event.detail?.name === 'troika-text' && attach()) {
21
+ el.removeEventListener('componentinitialized', onInitialized);
22
+ }
23
+ };
24
+ if (!attach())
25
+ el.addEventListener('componentinitialized', onInitialized);
26
+ return () => {
27
+ el.removeEventListener('componentinitialized', onInitialized);
28
+ mesh?.removeEventListener('synccomplete', report);
29
+ };
30
+ }
31
+ export function watchTextSize(el, onSize) {
32
+ return watchTextLayout(el, (info) => {
33
+ const bounds = info.blockBounds;
34
+ if (bounds)
35
+ onSize(bounds[2] - bounds[0], bounds[3] - bounds[1]);
36
+ });
37
+ }
38
+ // Sets troika's text on its own, not through the attribute string: A-Frame splits
39
+ // those on `;`, which a user can type.
40
+ export function setTroikaValue(el, value) {
41
+ el?.setAttribute('troika-text', 'value', value);
42
+ }
@@ -0,0 +1,88 @@
1
+ export type HeadPose = {
2
+ x: number;
3
+ y: number;
4
+ z: number;
5
+ yaw: number;
6
+ };
7
+ export type FollowOptions = {
8
+ distance: number;
9
+ height: number;
10
+ side: number;
11
+ };
12
+ export declare function followGoal(head: HeadPose, { distance, height, side }: FollowOptions): HeadPose;
13
+ export declare function angleDelta(a: number, b: number): number;
14
+ export declare function needsRecentre(panel: {
15
+ x: number;
16
+ y: number;
17
+ z: number;
18
+ }, head: HeadPose, goal: HeadPose, maxAngle: number, slack?: number): boolean;
19
+ export type VRPlacement = 'world' | 'follow' | 'hand';
20
+ export type DesktopPlacement = 'screen' | 'camera';
21
+ export declare function surfacePlacement({ vr, desktop }: {
22
+ vr: VRPlacement;
23
+ desktop: DesktopPlacement;
24
+ }, { immersive, presenting }: {
25
+ immersive: boolean;
26
+ presenting: boolean;
27
+ }): VRPlacement | DesktopPlacement;
28
+ export declare const easeStep: (dt: number, timeConstant: number) => number;
29
+ export declare function placementComponents(THREE: any): {
30
+ follow: {
31
+ schema: {
32
+ enabled: {
33
+ default: boolean;
34
+ };
35
+ distance: {
36
+ type: string;
37
+ default: number;
38
+ };
39
+ height: {
40
+ type: string;
41
+ default: number;
42
+ };
43
+ side: {
44
+ type: string;
45
+ default: number;
46
+ };
47
+ maxAngle: {
48
+ type: string;
49
+ default: number;
50
+ };
51
+ smoothing: {
52
+ type: string;
53
+ default: number;
54
+ };
55
+ };
56
+ init(this: any): void;
57
+ update(this: any, old: any): void;
58
+ remove(this: any): void;
59
+ tick(this: any, _t: number, dt: number): void;
60
+ };
61
+ attach: {
62
+ schema: {
63
+ target: {
64
+ type: string;
65
+ };
66
+ position: {
67
+ type: string;
68
+ default: {
69
+ x: number;
70
+ y: number;
71
+ z: number;
72
+ };
73
+ };
74
+ rotation: {
75
+ type: string;
76
+ default: {
77
+ x: number;
78
+ y: number;
79
+ z: number;
80
+ };
81
+ };
82
+ };
83
+ init(this: any): void;
84
+ update(this: any): void;
85
+ tick(this: any): void;
86
+ };
87
+ };
88
+ export declare function registerPlacement(AFRAME?: any): void;
@@ -0,0 +1,163 @@
1
+ // Placement components for 3D Surfaces, registered by ensure3d():
2
+ // ui-follow — "lazy follow": the panel stays put while you look around a little,
3
+ // then glides back in front of you when it's out of a comfort cone.
4
+ // ui-attach — copies another entity's pose plus an offset each frame (wrist menus).
5
+ // Both work in world space, so the entity should not be a child of the camera.
6
+ // Erasable TypeScript only — tests import it with Node's type stripping.
7
+ // Where a follow panel wants to be: `distance` in front of the head (level, ignoring
8
+ // pitch), `height` relative to the eyes, `side` to the right; turned to face the head.
9
+ export function followGoal(head, { distance, height, side }) {
10
+ const forward = { x: -Math.sin(head.yaw), z: -Math.cos(head.yaw) };
11
+ const right = { x: Math.cos(head.yaw), z: -Math.sin(head.yaw) };
12
+ return {
13
+ x: head.x + forward.x * distance + right.x * side,
14
+ y: head.y + height,
15
+ z: head.z + forward.z * distance + right.z * side,
16
+ yaw: head.yaw,
17
+ };
18
+ }
19
+ // Smallest signed difference b - a between two angles (radians).
20
+ export function angleDelta(a, b) {
21
+ return Math.atan2(Math.sin(b - a), Math.cos(b - a));
22
+ }
23
+ // True when the panel has drifted far enough from its goal that it should move:
24
+ // outside `maxAngle` (degrees, around the head), or nearer/farther/higher/lower than
25
+ // the goal by more than `slack` meters.
26
+ export function needsRecentre(panel, head, goal, maxAngle, slack = 0.3) {
27
+ const bearing = (p) => Math.atan2(p.x - head.x, p.z - head.z);
28
+ const reach = (p) => Math.hypot(p.x - head.x, p.z - head.z);
29
+ return (Math.abs(angleDelta(bearing(panel), bearing(goal))) > (maxAngle * Math.PI) / 180 ||
30
+ Math.abs(reach(panel) - reach(goal)) > slack ||
31
+ Math.abs(panel.y - goal.y) > slack);
32
+ }
33
+ export function surfacePlacement({ vr, desktop }, { immersive, presenting }) {
34
+ if (!immersive)
35
+ return desktop;
36
+ return vr === 'hand' && !presenting ? 'follow' : vr;
37
+ }
38
+ // Fraction of the remaining distance to cover this frame, for exponential easing.
39
+ export const easeStep = (dt, timeConstant) => (timeConstant > 0 ? 1 - Math.exp(-dt / timeConstant) : 1);
40
+ export function placementComponents(THREE) {
41
+ const v = new THREE.Vector3();
42
+ const q = new THREE.Quaternion();
43
+ const e = new THREE.Euler();
44
+ const m = new THREE.Matrix4();
45
+ // Sets an object's world position and yaw, whatever its parent's transform.
46
+ function setWorldPose(obj, x, y, z, yaw) {
47
+ const parent = obj.parent;
48
+ v.set(x, y, z);
49
+ q.setFromEuler(e.set(0, yaw, 0, 'YXZ'));
50
+ if (parent) {
51
+ parent.updateWorldMatrix(true, false);
52
+ parent.worldToLocal(v);
53
+ const parentQ = new THREE.Quaternion();
54
+ parent.getWorldQuaternion(parentQ);
55
+ q.premultiply(parentQ.invert());
56
+ }
57
+ obj.position.copy(v);
58
+ obj.quaternion.copy(q);
59
+ }
60
+ const follow = {
61
+ schema: {
62
+ enabled: { default: true },
63
+ distance: { type: 'number', default: 1.2 },
64
+ height: { type: 'number', default: -0.2 },
65
+ side: { type: 'number', default: 0 },
66
+ maxAngle: { type: 'number', default: 30 },
67
+ // ms; how quickly it catches up once it moves.
68
+ smoothing: { type: 'number', default: 250 },
69
+ },
70
+ init() {
71
+ this.snap = true;
72
+ this.moving = false;
73
+ this.resnap = () => (this.snap = true);
74
+ this.el.sceneEl?.addEventListener('enter-vr', this.resnap);
75
+ this.el.sceneEl?.addEventListener('exit-vr', this.resnap);
76
+ },
77
+ update(old) {
78
+ if (this.data.enabled && !old?.enabled)
79
+ this.snap = true;
80
+ },
81
+ remove() {
82
+ this.el.sceneEl?.removeEventListener('enter-vr', this.resnap);
83
+ this.el.sceneEl?.removeEventListener('exit-vr', this.resnap);
84
+ },
85
+ tick(_t, dt) {
86
+ const camera = this.el.sceneEl?.camera;
87
+ if (!this.data.enabled || !camera)
88
+ return;
89
+ camera.getWorldPosition(v);
90
+ camera.getWorldQuaternion(q);
91
+ e.setFromQuaternion(q, 'YXZ');
92
+ const head = { x: v.x, y: v.y, z: v.z, yaw: e.y };
93
+ const goal = followGoal(head, this.data);
94
+ const obj = this.el.object3D;
95
+ obj.getWorldPosition(v);
96
+ const panel = { x: v.x, y: v.y, z: v.z };
97
+ if (this.snap) {
98
+ this.snap = false;
99
+ this.moving = false;
100
+ this.yaw = goal.yaw;
101
+ setWorldPose(obj, goal.x, goal.y, goal.z, goal.yaw);
102
+ return;
103
+ }
104
+ if (!this.moving && needsRecentre(panel, head, goal, this.data.maxAngle))
105
+ this.moving = true;
106
+ if (!this.moving)
107
+ return;
108
+ const k = easeStep(dt || 16, this.data.smoothing);
109
+ const next = {
110
+ x: panel.x + (goal.x - panel.x) * k,
111
+ y: panel.y + (goal.y - panel.y) * k,
112
+ z: panel.z + (goal.z - panel.z) * k,
113
+ };
114
+ this.yaw += angleDelta(this.yaw, goal.yaw) * k;
115
+ setWorldPose(obj, next.x, next.y, next.z, this.yaw);
116
+ const left = Math.hypot(goal.x - next.x, goal.y - next.y, goal.z - next.z);
117
+ if (left < 0.005 && Math.abs(angleDelta(this.yaw, goal.yaw)) < 0.01)
118
+ this.moving = false;
119
+ },
120
+ };
121
+ const attach = {
122
+ schema: {
123
+ target: { type: 'selector' },
124
+ // Offset in the target's space (meters / degrees).
125
+ position: { type: 'vec3', default: { x: 0, y: 0.1, z: -0.05 } },
126
+ rotation: { type: 'vec3', default: { x: -50, y: 0, z: 0 } },
127
+ },
128
+ init() {
129
+ this.offset = new THREE.Matrix4();
130
+ this.scale = new THREE.Vector3();
131
+ },
132
+ update() {
133
+ const { position: p, rotation: r } = this.data;
134
+ const rad = THREE.MathUtils.degToRad;
135
+ this.offset.compose(new THREE.Vector3(p.x, p.y, p.z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rad(r.x), rad(r.y), rad(r.z))), new THREE.Vector3(1, 1, 1));
136
+ },
137
+ tick() {
138
+ const target = this.data.target?.object3D;
139
+ const obj = this.el.object3D;
140
+ if (!target)
141
+ return;
142
+ // updateWorldMatrix(true, …) includes the ancestors: a rig that moved this frame
143
+ // (walking, teleport) hasn't had its matrixWorld updated yet, which would leave
144
+ // us a frame behind — visible jitter on a camera-attached menu while walking.
145
+ target.updateWorldMatrix(true, false);
146
+ m.multiplyMatrices(target.matrixWorld, this.offset);
147
+ if (obj.parent) {
148
+ obj.parent.updateWorldMatrix(true, false);
149
+ m.premultiply(new THREE.Matrix4().copy(obj.parent.matrixWorld).invert());
150
+ }
151
+ // Keep our own scale; take only position and rotation from the target.
152
+ m.decompose(obj.position, obj.quaternion, this.scale);
153
+ },
154
+ };
155
+ return { follow, attach };
156
+ }
157
+ export function registerPlacement(AFRAME = globalThis.AFRAME) {
158
+ const { follow, attach } = placementComponents(AFRAME.THREE);
159
+ if (!AFRAME.components['ui-follow'])
160
+ AFRAME.registerComponent('ui-follow', follow);
161
+ if (!AFRAME.components['ui-attach'])
162
+ AFRAME.registerComponent('ui-attach', attach);
163
+ }
@@ -0,0 +1,61 @@
1
+ export declare function roundedComponent(THREE: any): {
2
+ schema: {
3
+ enabled: {
4
+ default: boolean;
5
+ };
6
+ width: {
7
+ type: string;
8
+ default: number;
9
+ };
10
+ height: {
11
+ type: string;
12
+ default: number;
13
+ };
14
+ depth: {
15
+ type: string;
16
+ default: number;
17
+ };
18
+ radius: {
19
+ type: string;
20
+ default: number;
21
+ };
22
+ topLeftRadius: {
23
+ type: string;
24
+ default: number;
25
+ };
26
+ topRightRadius: {
27
+ type: string;
28
+ default: number;
29
+ };
30
+ bottomLeftRadius: {
31
+ type: string;
32
+ default: number;
33
+ };
34
+ bottomRightRadius: {
35
+ type: string;
36
+ default: number;
37
+ };
38
+ color: {
39
+ type: string;
40
+ default: string;
41
+ };
42
+ opacity: {
43
+ type: string;
44
+ default: number;
45
+ };
46
+ glass: {
47
+ type: string;
48
+ default: boolean;
49
+ };
50
+ flat: {
51
+ type: string;
52
+ default: boolean;
53
+ };
54
+ };
55
+ init(this: any): void;
56
+ update(this: any): void;
57
+ updateOpacity(this: any): void;
58
+ remove(this: any): void;
59
+ draw(this: any): any;
60
+ };
61
+ export declare function registerRounded(AFRAME?: any): void;