@opsydyn/effect-three 0.2.0 → 0.4.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 (134) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +14 -1
  3. package/dist/{index-01kwfekm.js → index-h3vf50fn.js} +2418 -85
  4. package/dist/index-h3vf50fn.js.map +53 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +543 -149
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/behaviours.d.ts.map +1 -1
  10. package/dist/kernel/boxSelect.d.ts +22 -0
  11. package/dist/kernel/boxSelect.d.ts.map +1 -0
  12. package/dist/kernel/build.d.ts +197 -6
  13. package/dist/kernel/build.d.ts.map +1 -1
  14. package/dist/kernel/cameraMotion.d.ts +3 -1
  15. package/dist/kernel/cameraMotion.d.ts.map +1 -1
  16. package/dist/kernel/cameraShake.d.ts +33 -0
  17. package/dist/kernel/cameraShake.d.ts.map +1 -0
  18. package/dist/kernel/describeScene.d.ts +50 -0
  19. package/dist/kernel/describeScene.d.ts.map +1 -0
  20. package/dist/kernel/description.d.ts +240 -2
  21. package/dist/kernel/description.d.ts.map +1 -1
  22. package/dist/kernel/diff.d.ts +18 -1
  23. package/dist/kernel/diff.d.ts.map +1 -1
  24. package/dist/kernel/equality.d.ts +13 -1
  25. package/dist/kernel/equality.d.ts.map +1 -1
  26. package/dist/kernel/gizmo.d.ts +69 -0
  27. package/dist/kernel/gizmo.d.ts.map +1 -0
  28. package/dist/kernel/history.d.ts +61 -0
  29. package/dist/kernel/history.d.ts.map +1 -0
  30. package/dist/kernel/index.d.ts +13 -2
  31. package/dist/kernel/index.d.ts.map +1 -1
  32. package/dist/kernel/index.js +116 -3
  33. package/dist/kernel/index.js.map +1 -1
  34. package/dist/kernel/inspect.d.ts +85 -0
  35. package/dist/kernel/inspect.d.ts.map +1 -0
  36. package/dist/kernel/lineGeometry.d.ts +25 -0
  37. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  38. package/dist/kernel/lockstep.d.ts +114 -0
  39. package/dist/kernel/lockstep.d.ts.map +1 -0
  40. package/dist/kernel/loop.d.ts.map +1 -1
  41. package/dist/kernel/materials.d.ts.map +1 -1
  42. package/dist/kernel/pivot.d.ts +54 -0
  43. package/dist/kernel/pivot.d.ts.map +1 -0
  44. package/dist/kernel/pointBuffers.d.ts +17 -0
  45. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  46. package/dist/kernel/pointer.d.ts +12 -0
  47. package/dist/kernel/pointer.d.ts.map +1 -1
  48. package/dist/kernel/renderKit.d.ts +21 -1
  49. package/dist/kernel/renderKit.d.ts.map +1 -1
  50. package/dist/kernel/renderer.d.ts +6 -0
  51. package/dist/kernel/renderer.d.ts.map +1 -1
  52. package/dist/kernel/resources.d.ts.map +1 -1
  53. package/dist/kernel/sceneChanges.d.ts +87 -0
  54. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  55. package/dist/kernel/sceneGraph.d.ts +6 -1
  56. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  57. package/dist/kernel/seeded.d.ts +9 -0
  58. package/dist/kernel/seeded.d.ts.map +1 -0
  59. package/dist/kernel/shots.d.ts +26 -0
  60. package/dist/kernel/shots.d.ts.map +1 -0
  61. package/dist/kernel/tape.d.ts +38 -0
  62. package/dist/kernel/tape.d.ts.map +1 -0
  63. package/dist/kernel/trail.d.ts +31 -0
  64. package/dist/kernel/trail.d.ts.map +1 -0
  65. package/dist/kernel/viewportGizmo.d.ts +25 -0
  66. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  67. package/dist/runtime/Inspector.d.ts +20 -0
  68. package/dist/runtime/Inspector.d.ts.map +1 -0
  69. package/dist/runtime/SceneFile.d.ts +448 -0
  70. package/dist/runtime/SceneFile.d.ts.map +1 -0
  71. package/dist/runtime/SceneSchema.d.ts +614 -5
  72. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  73. package/dist/runtime/errors.d.ts +9 -0
  74. package/dist/runtime/errors.d.ts.map +1 -1
  75. package/dist/runtime/index.d.ts +3 -1
  76. package/dist/runtime/index.d.ts.map +1 -1
  77. package/dist/webgl/index.d.ts.map +1 -1
  78. package/dist/webgl/index.js +228 -3
  79. package/dist/webgl/index.js.map +6 -4
  80. package/dist/webgl/line.d.ts +4 -0
  81. package/dist/webgl/line.d.ts.map +1 -0
  82. package/dist/webgl/points.d.ts +4 -0
  83. package/dist/webgl/points.d.ts.map +1 -0
  84. package/dist/webgpu/index.d.ts.map +1 -1
  85. package/dist/webgpu/index.js +206 -26
  86. package/dist/webgpu/index.js.map +6 -4
  87. package/dist/webgpu/line.d.ts +4 -0
  88. package/dist/webgpu/line.d.ts.map +1 -0
  89. package/dist/webgpu/points.d.ts +4 -0
  90. package/dist/webgpu/points.d.ts.map +1 -0
  91. package/package.json +2 -1
  92. package/src/kernel/behaviours.ts +5 -0
  93. package/src/kernel/bodySources.ts +1 -1
  94. package/src/kernel/boxSelect.ts +73 -0
  95. package/src/kernel/build.ts +430 -0
  96. package/src/kernel/cameraMotion.ts +3 -0
  97. package/src/kernel/cameraShake.ts +87 -0
  98. package/src/kernel/describeScene.ts +224 -0
  99. package/src/kernel/description.ts +266 -1
  100. package/src/kernel/diff.ts +46 -1
  101. package/src/kernel/equality.ts +148 -0
  102. package/src/kernel/gizmo.ts +478 -0
  103. package/src/kernel/history.ts +118 -0
  104. package/src/kernel/index.ts +102 -0
  105. package/src/kernel/inspect.ts +214 -0
  106. package/src/kernel/lineGeometry.ts +75 -0
  107. package/src/kernel/lockstep.ts +237 -0
  108. package/src/kernel/loop.ts +5 -0
  109. package/src/kernel/materials.ts +3 -0
  110. package/src/kernel/pivot.ts +418 -0
  111. package/src/kernel/pointBuffers.ts +115 -0
  112. package/src/kernel/pointer.ts +63 -12
  113. package/src/kernel/renderKit.ts +23 -1
  114. package/src/kernel/renderer.ts +6 -0
  115. package/src/kernel/resources.ts +69 -0
  116. package/src/kernel/sceneChanges.ts +265 -0
  117. package/src/kernel/sceneGraph.ts +461 -9
  118. package/src/kernel/seeded.ts +18 -0
  119. package/src/kernel/shots.ts +167 -0
  120. package/src/kernel/tape.ts +124 -0
  121. package/src/kernel/trail.ts +170 -0
  122. package/src/kernel/viewportGizmo.ts +194 -0
  123. package/src/runtime/Inspector.ts +43 -0
  124. package/src/runtime/SceneFile.ts +46 -0
  125. package/src/runtime/SceneSchema.ts +305 -4
  126. package/src/runtime/errors.ts +6 -0
  127. package/src/runtime/index.ts +3 -1
  128. package/src/webgl/index.ts +4 -0
  129. package/src/webgl/line.ts +66 -0
  130. package/src/webgl/points.ts +205 -0
  131. package/src/webgpu/index.ts +4 -0
  132. package/src/webgpu/line.ts +67 -0
  133. package/src/webgpu/points.ts +193 -0
  134. package/dist/index-01kwfekm.js.map +0 -40
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Lockstep, as data (roadmap, "Beyond parity" 2): peers that run the same `update` stay
3
+ * in the same state by applying the same inputs in the same order. Time is cut into
4
+ * turns; every member sends one packet for each turn (empty when it did nothing), and a
5
+ * turn applies only once every member's packet for it has arrived, its inputs in peer
6
+ * order. Any transport that delivers each packet to every peer will do: a
7
+ * `BroadcastChannel` between tabs, a socket relay between machines.
8
+ *
9
+ * Membership changes in step too. The leader (the member whose id sorts first) admits
10
+ * a newcomer with an entry in its own packet; when that turn applies, everywhere at
11
+ * once, the newcomer becomes a member `lead + 1` turns later, and the leader sends it
12
+ * the state as it is then (`welcomeDue`), from which it carries on. A member that leaves
13
+ * says which turn it sent last (`leave`); one that goes quiet is dropped by the leader
14
+ * from the turn everyone is waiting on, which no one can have applied.
15
+ *
16
+ * Pure functions over a value, like the rest of the kernel: an app keeps the `Lockstep`
17
+ * in its Model, sends what `send` gives it, passes what arrives to `receive`, and
18
+ * applies what `advance` returns. Use it as a namespace: `Lockstep.make(self)`.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ /** What a turn carries: an app's input, or the leader admitting a newcomer. */
24
+ export type Entry<I> =
25
+ | { readonly _tag: 'Input'; readonly input: I }
26
+ | { readonly _tag: 'Admit'; readonly peer: string };
27
+
28
+ /** One member's packet for one turn. */
29
+ export type Packet<I> = {
30
+ readonly peer: string;
31
+ readonly turn: number;
32
+ readonly entries: ReadonlyArray<Entry<I>>;
33
+ };
34
+
35
+ /** A member: the first turn it sends for, and the turn it stopped before, once it has. */
36
+ export type Member = {
37
+ readonly peer: string;
38
+ readonly from: number;
39
+ readonly until: number | null;
40
+ };
41
+
42
+ export type Lockstep<I> = {
43
+ readonly self: string;
44
+ readonly members: ReadonlyArray<Member>;
45
+ /** The next turn to apply. */
46
+ readonly turn: number;
47
+ /** The next turn this peer sends for. */
48
+ readonly sent: number;
49
+ /** How many turns ahead of the applied one a peer may send: inputs wait this long. */
50
+ readonly lead: number;
51
+ /** Entries waiting for this peer's next packet. */
52
+ readonly pending: ReadonlyArray<Entry<I>>;
53
+ /** Packets for turns not yet applied, this peer's own among them. */
54
+ readonly received: ReadonlyArray<Packet<I>>;
55
+ };
56
+
57
+ /** An input applied: the turn, and the peer it came from. */
58
+ export type Applied<I> = { readonly turn: number; readonly peer: string; readonly input: I };
59
+
60
+ const sortedPeers = (members: ReadonlyArray<Member>): ReadonlyArray<Member> =>
61
+ members.toSorted((a, b) => (a.peer < b.peer ? -1 : a.peer > b.peer ? 1 : 0));
62
+
63
+ const memberAt = (member: Member, turn: number): boolean =>
64
+ member.from <= turn && (member.until === null || turn < member.until);
65
+
66
+ /** The members a turn waits for, in the order their inputs apply. */
67
+ export const membersAt = <I>(state: Lockstep<I>, turn: number): ReadonlyArray<string> =>
68
+ sortedPeers(state.members.filter((member) => memberAt(member, turn))).map(
69
+ (member) => member.peer,
70
+ );
71
+
72
+ /** The member who admits and drops others at `turn`: the one whose id sorts first. */
73
+ export const leaderAt = <I>(state: Lockstep<I>, turn: number): string | undefined =>
74
+ membersAt(state, turn)[0];
75
+
76
+ /** A session of one: this peer alone, from turn 0. */
77
+ export const make = <I>(self: string, lead = 2): Lockstep<I> => ({
78
+ self,
79
+ members: [{ peer: self, from: 0, until: null }],
80
+ turn: 0,
81
+ sent: 0,
82
+ lead,
83
+ pending: [],
84
+ received: [],
85
+ });
86
+
87
+ /**
88
+ * A newcomer's session, from the leader's welcome: the members and the turn it starts
89
+ * at, with whatever packets it heard while it waited.
90
+ */
91
+ export const join = <I>(
92
+ self: string,
93
+ welcome: {
94
+ readonly turn: number;
95
+ readonly members: ReadonlyArray<Member>;
96
+ readonly lead: number;
97
+ },
98
+ heard: ReadonlyArray<Packet<I>> = [],
99
+ ): Lockstep<I> => ({
100
+ self,
101
+ members: welcome.members,
102
+ turn: welcome.turn,
103
+ sent: welcome.turn,
104
+ lead: welcome.lead,
105
+ pending: [],
106
+ received: heard.filter((packet) => packet.turn >= welcome.turn),
107
+ });
108
+
109
+ /** `input` waits for this peer's next packet. */
110
+ export const submit = <I>(state: Lockstep<I>, input: I): Lockstep<I> => ({
111
+ ...state,
112
+ pending: [...state.pending, { _tag: 'Input', input }],
113
+ });
114
+
115
+ const known = <I>(state: Lockstep<I>, peer: string): boolean =>
116
+ state.members.some((member) => member.peer === peer && member.until === null) ||
117
+ state.pending.some((entry) => entry._tag === 'Admit' && entry.peer === peer);
118
+
119
+ /** The leader asks for `peer` to be admitted; anyone else, or a known peer, changes nothing. */
120
+ export const admit = <I>(state: Lockstep<I>, peer: string): Lockstep<I> =>
121
+ leaderAt(state, state.sent) !== state.self || known(state, peer)
122
+ ? state
123
+ : { ...state, pending: [...state.pending, { _tag: 'Admit', peer }] };
124
+
125
+ const hasPacket = <I>(state: Lockstep<I>, peer: string, turn: number): boolean =>
126
+ state.received.some((packet) => packet.peer === peer && packet.turn === turn);
127
+
128
+ /**
129
+ * A packet arriving: kept until its turn applies. Ones for turns already applied, from
130
+ * peers not members then, and second copies are dropped.
131
+ */
132
+ export const receive = <I>(state: Lockstep<I>, packet: Packet<I>): Lockstep<I> => {
133
+ if (packet.turn < state.turn || hasPacket(state, packet.peer, packet.turn)) return state;
134
+ const member = state.members.find((each) => each.peer === packet.peer);
135
+ if (member !== undefined && !memberAt(member, packet.turn)) return state;
136
+ return { ...state, received: [...state.received, packet] };
137
+ };
138
+
139
+ /**
140
+ * This peer's packet for its next turn, with what is pending, when it is a member then
141
+ * and not more than `lead` turns ahead; it keeps its own copy.
142
+ */
143
+ export const send = <I>(
144
+ state: Lockstep<I>,
145
+ ): { readonly state: Lockstep<I>; readonly packet: Packet<I> | undefined } => {
146
+ const member = state.members.find((each) => each.peer === state.self);
147
+ const ahead = state.sent >= state.turn + state.lead;
148
+ if (member === undefined || !memberAt(member, state.sent) || ahead) {
149
+ return { state, packet: undefined };
150
+ }
151
+ const packet: Packet<I> = { peer: state.self, turn: state.sent, entries: state.pending };
152
+ return {
153
+ state: { ...state, sent: state.sent + 1, pending: [], received: [...state.received, packet] },
154
+ packet,
155
+ };
156
+ };
157
+
158
+ /** Peers whose packet for the next turn has not arrived: what the session waits on. */
159
+ export const waitingOn = <I>(state: Lockstep<I>): ReadonlyArray<string> =>
160
+ membersAt(state, state.turn).filter((peer) => !hasPacket(state, peer, state.turn));
161
+
162
+ /** `peer`'s packet for `turn`, as a list of none or one. */
163
+ const packetsOf = <I>(state: Lockstep<I>, peer: string, turn: number): ReadonlyArray<Packet<I>> =>
164
+ state.received.filter((packet) => packet.peer === peer && packet.turn === turn);
165
+
166
+ /** One turn applied, its admissions made members `lead + 1` turns on. */
167
+ const applyTurn = <I>(
168
+ state: Lockstep<I>,
169
+ ): { state: Lockstep<I>; applied: ReadonlyArray<Applied<I>> } => {
170
+ const turn = state.turn;
171
+ const packets = membersAt(state, turn).flatMap((peer) => packetsOf(state, peer, turn));
172
+ const entries = packets.flatMap((packet) =>
173
+ packet.entries.map((entry) => ({ peer: packet.peer, entry })),
174
+ );
175
+ const admitted = entries.flatMap(({ entry }) =>
176
+ entry._tag === 'Admit' && !state.members.some((member) => member.peer === entry.peer)
177
+ ? [{ peer: entry.peer, from: turn + state.lead + 1, until: null }]
178
+ : [],
179
+ );
180
+ const applied = entries.flatMap(({ peer, entry }) =>
181
+ entry._tag === 'Input' ? [{ turn, peer, input: entry.input }] : [],
182
+ );
183
+ return {
184
+ state: {
185
+ ...state,
186
+ turn: turn + 1,
187
+ members: [...state.members, ...admitted],
188
+ received: state.received.filter((packet) => packet.turn > turn),
189
+ },
190
+ applied,
191
+ };
192
+ };
193
+
194
+ /**
195
+ * Every turn whose packets have all arrived, applied in order: the new state, and the
196
+ * inputs to apply to the app, oldest first and in peer order within a turn.
197
+ */
198
+ export const advance = <I>(
199
+ state: Lockstep<I>,
200
+ ): { readonly state: Lockstep<I>; readonly applied: ReadonlyArray<Applied<I>> } => {
201
+ let current = state;
202
+ const applied: Array<Applied<I>> = [];
203
+ // A turn with no members waits forever rather than passing unheard.
204
+ while (membersAt(current, current.turn).length > 0 && waitingOn(current).length === 0) {
205
+ const next = applyTurn(current);
206
+ current = next.state;
207
+ applied.push(...next.applied);
208
+ }
209
+ return { state: current, applied };
210
+ };
211
+
212
+ /**
213
+ * Newcomers this peer, as leader, should welcome now: those who become members at the
214
+ * turn about to apply, once every turn before it has (the state is then theirs to start
215
+ * from). Each is due once: at the moment `before` (the previous turn) is passed.
216
+ */
217
+ export const welcomeDue = <I>(before: number, state: Lockstep<I>): ReadonlyArray<string> =>
218
+ state.members
219
+ .filter((member) => member.from > before && member.from <= state.turn)
220
+ // The leader of the turn before the newcomer counts: never the newcomer itself, whose
221
+ // id may sort first.
222
+ .filter((member) => leaderAt(state, member.from - 1) === state.self)
223
+ .map((member) => member.peer);
224
+
225
+ /** This peer leaving: the turn it sent last, for the others to stop waiting after. */
226
+ export const leave = <I>(state: Lockstep<I>): number => state.sent - 1;
227
+
228
+ /** `peer` stops being a member from `until`; its packets from then are dropped. */
229
+ export const remove = <I>(state: Lockstep<I>, peer: string, until: number): Lockstep<I> => ({
230
+ ...state,
231
+ members: state.members.map((member) =>
232
+ member.peer === peer && (member.until === null || until < member.until)
233
+ ? { ...member, until }
234
+ : member,
235
+ ),
236
+ received: state.received.filter((packet) => packet.peer !== peer || packet.turn < until),
237
+ });
@@ -215,6 +215,11 @@ export const createSceneLoop = (options: SceneLoopOptions): SceneLoop => {
215
215
  const views = graph.views();
216
216
  if (views.length === 0) renderer.render(graph.scene, graph.camera);
217
217
  for (const view of views) renderer.render(graph.scene, view.camera, view.area);
218
+ const hud = graph.hud();
219
+ if (hud !== undefined && renderer.renderOver !== undefined) {
220
+ renderer.renderOver(hud.graph.scene, hud.graph.camera);
221
+ hud.graph.drawn();
222
+ }
218
223
  graph.drawn();
219
224
  measure();
220
225
  const overlays = graph.overlays();
@@ -108,6 +108,9 @@ const applyBasic = (
108
108
  textures: Textures,
109
109
  ): void => {
110
110
  material.color.setHex(description.color);
111
+ // Drawn over everything when not depth-tested, and leaving no depth behind.
112
+ material.depthTest = description.depthTest;
113
+ material.depthWrite = description.depthTest;
111
114
  const mapChanged = applyMap(material, description.map, textures);
112
115
  if (applySurface(material, description) || mapChanged) material.needsUpdate = true;
113
116
  };
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Pivot controls as data, after Drei's `PivotControls` (`bf6f4ad`,
3
+ * `src/web/pivotControls/`): every handle at once, turned with the object and sized in
4
+ * scene units — an arrow along each axis, a square sliding in each plane, a quarter arc
5
+ * turning round each axis, and a sphere scaling along each. Drei's listen to the pointer
6
+ * and move a group themselves; here `pivotNodes` describes the handles from the Model, a
7
+ * press on one (`pressPivot`, with the point the press hit) and the drag rays after it
8
+ * (`dragPivot`) return the next placement, and the Model keeps both.
9
+ *
10
+ * The maths is Drei's: an arrow and a sphere follow the point on their axis nearest the
11
+ * ray (`calculateOffset`); a square and an arc follow where the ray's line meets their
12
+ * plane, a square by its offset along the plane's two axes, an arc by the angle turned
13
+ * about the object (`calculateAngle`); a sphere scales by 2 to the power of a fifth of
14
+ * the distance dragged, in handle sizes. Snapping is Drei's shift: ten degrees, and
15
+ * scales to a tenth.
16
+ *
17
+ * Placements are in the object's parent's space, which is taken to be the world, as for
18
+ * `gizmoNodes`.
19
+ *
20
+ * @module
21
+ */
22
+ import { Euler, Quaternion, Vector3 } from 'three';
23
+
24
+ import { basic, cone, cylinder, fixed, group, mesh, plane, sphere } from './build.ts';
25
+ import type { SceneNode, Vec3 } from './description.ts';
26
+ import type { Placement } from './gizmo.ts';
27
+ import type { Ray } from './pointer.ts';
28
+
29
+ type Axis = 'x' | 'y' | 'z';
30
+
31
+ /** A handle: what it does, and the axis it is on (a square's and an arc's normal). */
32
+ export type PivotHandle = `${'arrow' | 'slider' | 'rotator' | 'sphere'}-${Axis}`;
33
+
34
+ /** A drag in progress: the handle, the placement it began at and the frame it moves in. */
35
+ export type PivotGrab = {
36
+ readonly handle: PivotHandle;
37
+ readonly start: Placement;
38
+ /** Where the press hit the handle. */
39
+ readonly point: Vec3;
40
+ /** The handle's directions in the world: its axis, or its plane's two and normal. */
41
+ readonly e1: Vec3;
42
+ readonly e2: Vec3;
43
+ readonly normal: Vec3;
44
+ };
45
+
46
+ export type Pivot = {
47
+ /** The handles' size in scene units: an arrow's length (Drei's `scale`, 1). */
48
+ readonly scale: number;
49
+ /** Whether the object hides the handles behind it (Drei's `depthTest`, true). */
50
+ readonly depthTest: boolean;
51
+ /** Leave out kinds of handle, as Drei's `disable*`. */
52
+ readonly disableAxes: boolean;
53
+ readonly disableSliders: boolean;
54
+ readonly disableRotations: boolean;
55
+ readonly disableScaling: boolean;
56
+ /** The handle under the pointer, drawn in Drei's hovered yellow. */
57
+ readonly hovered: PivotHandle | null;
58
+ readonly grab: PivotGrab | null;
59
+ };
60
+
61
+ /** Pivot controls with Drei's defaults: size 1, depth tested, every handle shown. */
62
+ export const pivot = (options: Partial<Omit<Pivot, 'hovered' | 'grab'>> = {}): Pivot => ({
63
+ scale: options.scale ?? 1,
64
+ depthTest: options.depthTest ?? true,
65
+ disableAxes: options.disableAxes ?? false,
66
+ disableSliders: options.disableSliders ?? false,
67
+ disableRotations: options.disableRotations ?? false,
68
+ disableScaling: options.disableScaling ?? false,
69
+ hovered: null,
70
+ grab: null,
71
+ });
72
+
73
+ const unit: Record<Axis, Vector3> = {
74
+ x: new Vector3(1, 0, 0),
75
+ y: new Vector3(0, 1, 0),
76
+ z: new Vector3(0, 0, 1),
77
+ };
78
+
79
+ /** Each plane's two directions, by its normal, in Drei's order. */
80
+ const planeOf: Record<Axis, readonly [Axis, Axis]> = {
81
+ z: ['x', 'y'],
82
+ y: ['z', 'x'],
83
+ x: ['y', 'z'],
84
+ };
85
+
86
+ const turnOf = (rotation: Vec3): Quaternion =>
87
+ new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2]));
88
+
89
+ const toVec3 = (vector: Vector3): Vec3 => [vector.x, vector.y, vector.z];
90
+
91
+ const kindOf = (handle: PivotHandle) =>
92
+ handle.split('-')[0] as 'arrow' | 'slider' | 'rotator' | 'sphere';
93
+ const axisOf = (handle: PivotHandle): Axis => handle.split('-')[1] as Axis;
94
+
95
+ /** The handle's frame in the world: turned with the object, as Drei's group is. */
96
+ const frameOf = (handle: PivotHandle, placement: Placement) => {
97
+ const turn = turnOf(placement.rotation);
98
+ const axis = axisOf(handle);
99
+ const [first, second] = planeOf[axis];
100
+ const kind = kindOf(handle);
101
+ const flat = kind === 'slider' || kind === 'rotator';
102
+ const e1 = (flat ? unit[first] : unit[axis]).clone().applyQuaternion(turn);
103
+ const e2 = (flat ? unit[second] : unit[axis]).clone().applyQuaternion(turn);
104
+ return { e1, e2, normal: e1.clone().cross(e2).normalize() };
105
+ };
106
+
107
+ /** A press on `handle` at `point`, with the object at `placement`: takes hold. */
108
+ export const pressPivot = (
109
+ state: Pivot,
110
+ placement: Placement,
111
+ handle: PivotHandle,
112
+ point: Vec3,
113
+ ): Pivot => {
114
+ const { e1, e2, normal } = frameOf(handle, placement);
115
+ return {
116
+ ...state,
117
+ grab: {
118
+ handle,
119
+ start: placement,
120
+ point,
121
+ e1: toVec3(e1),
122
+ e2: toVec3(e2),
123
+ normal: toVec3(kindOf(handle) === 'arrow' || kindOf(handle) === 'sphere' ? e1 : normal),
124
+ },
125
+ };
126
+ };
127
+
128
+ export const releasePivot = (state: Pivot): Pivot => ({ ...state, grab: null });
129
+
130
+ export const hoverPivot = (state: Pivot, handle: PivotHandle | null): Pivot =>
131
+ state.hovered === handle ? state : { ...state, hovered: handle };
132
+
133
+ /**
134
+ * Drei's `calculateOffset`: how far along `direction` from `point` the line nearest the
135
+ * ray is, solving for the closest points of the two lines.
136
+ */
137
+ const offsetAlong = (point: Vector3, direction: Vector3, ray: Ray): number => {
138
+ const origin = new Vector3(...ray.origin);
139
+ const toward = new Vector3(...ray.direction);
140
+ const e1 = direction.dot(direction);
141
+ const e2 = direction.dot(point) - direction.dot(origin);
142
+ const e3 = direction.dot(toward);
143
+ if (e3 === 0) return -e2 / e1;
144
+ const v1 = toward
145
+ .clone()
146
+ .multiplyScalar(e1 / e3)
147
+ .sub(direction);
148
+ const v2 = toward
149
+ .clone()
150
+ .multiplyScalar(e2 / e3)
151
+ .add(origin)
152
+ .sub(point);
153
+ return -v1.dot(v2) / v1.dot(v1);
154
+ };
155
+
156
+ /** Where the ray's line meets the plane through `at` with `normal`, if it does. */
157
+ const meetPlane = (ray: Ray, at: Vector3, normal: Vector3): Vector3 | undefined => {
158
+ const origin = new Vector3(...ray.origin);
159
+ const toward = new Vector3(...ray.direction);
160
+ const facing = normal.dot(toward);
161
+ if (facing === 0) return undefined;
162
+ return origin.addScaledVector(toward, normal.dot(at.clone().sub(origin)) / facing);
163
+ };
164
+
165
+ /** Drei's `calculateAngle`: from the press to `to`, round `at`, in the plane of e1 and e2. */
166
+ const angleBetween = (from: Vector3, to: Vector3, at: Vector3, e1: Vector3, e2: Vector3) => {
167
+ const a = from.clone().sub(at);
168
+ const b = to.clone().sub(at);
169
+ return Math.atan2(b.dot(e2), b.dot(e1)) - Math.atan2(a.dot(e2), a.dot(e1));
170
+ };
171
+
172
+ const vectorsOf = (grab: PivotGrab) => ({
173
+ point: new Vector3(...grab.point),
174
+ e1: new Vector3(...grab.e1),
175
+ e2: new Vector3(...grab.e2),
176
+ normal: new Vector3(...grab.normal),
177
+ at: new Vector3(...grab.start.position),
178
+ });
179
+
180
+ const moved = (start: Placement, by: Vector3): Placement => ({
181
+ ...start,
182
+ position: toVec3(new Vector3(...start.position).add(by)),
183
+ });
184
+
185
+ const arrowDrag = (grab: PivotGrab, ray: Ray): Placement => {
186
+ const { point, e1 } = vectorsOf(grab);
187
+ return moved(grab.start, e1.clone().multiplyScalar(offsetAlong(point, e1, ray)));
188
+ };
189
+
190
+ const sliderDrag = (grab: PivotGrab, ray: Ray): Placement => {
191
+ const { point, e1, e2, normal, at } = vectorsOf(grab);
192
+ const meet = meetPlane(ray, at, normal);
193
+ if (meet === undefined) return grab.start;
194
+ const offset = meet.sub(point);
195
+ return moved(
196
+ grab.start,
197
+ e1.multiplyScalar(offset.dot(e1)).add(e2.multiplyScalar(offset.dot(e2))),
198
+ );
199
+ };
200
+
201
+ const tenDegrees = Math.PI / 18;
202
+
203
+ const rotatorDrag = (grab: PivotGrab, ray: Ray, snap: boolean): Placement => {
204
+ const { point, e1, e2, normal, at } = vectorsOf(grab);
205
+ const meet = meetPlane(ray, at, normal);
206
+ if (meet === undefined) return grab.start;
207
+ const turned = angleBetween(point, meet, at, e1, e2);
208
+ const angle = snap ? Math.round(turned / tenDegrees) * tenDegrees : turned;
209
+ const turn = new Quaternion()
210
+ .setFromAxisAngle(normal, angle)
211
+ .multiply(turnOf(grab.start.rotation));
212
+ const euler = new Euler().setFromQuaternion(turn.normalize());
213
+ return { ...grab.start, rotation: [euler.x, euler.y, euler.z] };
214
+ };
215
+
216
+ const sphereDrag = (state: Pivot, grab: PivotGrab, ray: Ray, snap: boolean): Placement => {
217
+ const { point, e1 } = vectorsOf(grab);
218
+ const grown = 2 ** ((offsetAlong(point, e1, ray) / state.scale) * 0.2);
219
+ // Drei never lets a scale fall below 1e-5, which would break the object's matrix.
220
+ const factor = Math.max(snap ? Math.round(grown * 10) / 10 : grown, 1e-5);
221
+ const index = { x: 0, y: 1, z: 2 }[axisOf(grab.handle)];
222
+ const scale: Vec3 = [
223
+ grab.start.scale[0] * (index === 0 ? factor : 1),
224
+ grab.start.scale[1] * (index === 1 ? factor : 1),
225
+ grab.start.scale[2] * (index === 2 ? factor : 1),
226
+ ];
227
+ return { ...grab.start, scale };
228
+ };
229
+
230
+ /**
231
+ * The placement a drag ray gives, or `undefined` while nothing is held. `snap` rounds
232
+ * turns to ten degrees and scales to tenths, as Drei's shift does.
233
+ */
234
+ export const dragPivot = (
235
+ state: Pivot,
236
+ ray: Ray,
237
+ options: { readonly snap?: boolean } = {},
238
+ ): Placement | undefined => {
239
+ const grab = state.grab;
240
+ if (grab === null) return undefined;
241
+ const snap = options.snap ?? false;
242
+ const kind = kindOf(grab.handle);
243
+ if (kind === 'arrow') return arrowDrag(grab, ray);
244
+ if (kind === 'slider') return sliderDrag(grab, ray);
245
+ if (kind === 'rotator') return rotatorDrag(grab, ray, snap);
246
+ return sphereDrag(state, grab, ray, snap);
247
+ };
248
+
249
+ // DRAWING
250
+
251
+ /** Drei's `axisColors` and `hoveredColor`. */
252
+ const colours: Record<Axis, number> = { x: 0xff2060, y: 0x20df80, z: 0x2080ff };
253
+ const hoveredColour = 0xffff40;
254
+
255
+ const handles = ['arrow', 'slider', 'rotator', 'sphere'] as const;
256
+
257
+ /** The handle a hit names, if it is one of this pivot's. */
258
+ export const pivotHandleOf = (key: string, hit: string): PivotHandle | undefined => {
259
+ const [owner, name] = hit.split(':');
260
+ if (owner !== key) return undefined;
261
+ return handles
262
+ .flatMap((kind) => (['x', 'y', 'z'] as const).map((axis): PivotHandle => `${kind}-${axis}`))
263
+ .find((handle) => handle === name);
264
+ };
265
+
266
+ const lookOf = (state: Pivot, handle: PivotHandle) =>
267
+ basic({
268
+ color:
269
+ state.grab?.handle === handle || state.hovered === handle
270
+ ? hoveredColour
271
+ : colours[axisOf(handle)],
272
+ depthTest: state.depthTest,
273
+ side: 'double',
274
+ });
275
+
276
+ /**
277
+ * What a hit shape is drawn with: nothing to see, never hiding anything, still picked
278
+ * (Drei's arrows carry an invisible cylinder the same way).
279
+ */
280
+ const unseen = basic({ opacity: 0, transparent: true, depthTest: false });
281
+
282
+ /** Turns +y onto each axis, for the arrows, stems and spheres, which are drawn along y. */
283
+ const pointAlong: Record<Axis, Vec3> = {
284
+ x: [0, 0, -Math.PI / 2],
285
+ y: [0, 0, 0],
286
+ z: [Math.PI / 2, 0, 0],
287
+ };
288
+
289
+ const along = (axis: Axis, distance: number): Vec3 => {
290
+ const { x, y, z } = unit[axis];
291
+ return [x * distance, y * distance, z * distance];
292
+ };
293
+
294
+ /** Drei's `AxisArrow`: a stem of 4/5 of the size and a cone of 1/5, 1/20 wide. */
295
+ const arrowNodes = (key: string, state: Pivot, axis: Axis): ReadonlyArray<SceneNode> => {
296
+ const handle: PivotHandle = `arrow-${axis}`;
297
+ const size = state.scale;
298
+ const coneLength = size / 5;
299
+ const stem = size - coneLength;
300
+ const material = lookOf(state, handle);
301
+ return [
302
+ mesh({
303
+ key: `${key}:${handle}:stem`,
304
+ position: along(axis, stem / 2),
305
+ rotation: fixed(pointAlong[axis]),
306
+ geometry: cylinder({ radiusTop: size / 80, radiusBottom: size / 80, height: stem }),
307
+ material,
308
+ }),
309
+ // Drei's hit cylinder: 1.4 cone widths round, the arrow's whole length.
310
+ mesh({
311
+ key: `${key}:${handle}:hit`,
312
+ position: along(axis, size / 2),
313
+ rotation: fixed(pointAlong[axis]),
314
+ geometry: cylinder({ radiusTop: size * 0.07, radiusBottom: size * 0.07, height: size }),
315
+ material: unseen,
316
+ interactive: true,
317
+ }),
318
+ mesh({
319
+ key: `${key}:${handle}:cone`,
320
+ position: along(axis, stem + coneLength / 2),
321
+ rotation: fixed(pointAlong[axis]),
322
+ geometry: cone({ radius: size / 20, height: coneLength, radialSegments: 24 }),
323
+ material,
324
+ interactive: true,
325
+ }),
326
+ ];
327
+ };
328
+
329
+ /** A rotation onto the plane of an axis's slider and arc: x onto its first, y its second. */
330
+ const ontoPlane: Record<Axis, Vec3> = {
331
+ z: [0, 0, 0],
332
+ y: [-Math.PI / 2, 0, -Math.PI / 2],
333
+ x: [Math.PI / 2, Math.PI / 2, 0],
334
+ };
335
+
336
+ /** Drei's `PlaneSlider`: a square 0.225 of the size, its middle 1.7/7 out along both. */
337
+ const sliderNode = (key: string, state: Pivot, axis: Axis): SceneNode => {
338
+ const handle: PivotHandle = `slider-${axis}`;
339
+ const out = (state.scale / 7) * 1.7;
340
+ const side = state.scale * 0.225;
341
+ return group({
342
+ key: `${key}:${handle}`,
343
+ rotation: fixed(ontoPlane[axis]),
344
+ children: [
345
+ mesh({
346
+ key: `${key}:${handle}:square`,
347
+ position: [out, out, 0],
348
+ geometry: plane({ width: side, height: side }),
349
+ material: lookOf(state, handle),
350
+ interactive: true,
351
+ }),
352
+ ],
353
+ });
354
+ };
355
+
356
+ const arcSegments = 8;
357
+
358
+ /** Drei's `AxisRotator`: a quarter circle of radius 0.65 of the size, from its first to its second. */
359
+ const rotatorNode = (key: string, state: Pivot, axis: Axis): SceneNode => {
360
+ const handle: PivotHandle = `rotator-${axis}`;
361
+ const radius = state.scale * 0.65;
362
+ const step = Math.PI / 2 / arcSegments;
363
+ const chord = 2 * radius * Math.sin(step / 2);
364
+ // A cylinder lies along y: each turned to the arc's tangent at its middle. The thin one
365
+ // shows; a thick, unseen one round it is what the pointer finds, as Drei's line is
366
+ // picked a few pixels wide.
367
+ const segment = (index: number, part: 'line' | 'hit'): SceneNode => {
368
+ const middle = step * (index + 0.5);
369
+ const thickness = part === 'line' ? radius / 50 : state.scale * 0.03;
370
+ return mesh({
371
+ key: `${key}:${handle}:${part}:${index}`,
372
+ position: [Math.cos(middle) * radius, Math.sin(middle) * radius, 0],
373
+ rotation: fixed([0, 0, middle]),
374
+ geometry: cylinder({ radiusTop: thickness, radiusBottom: thickness, height: chord }),
375
+ material: part === 'line' ? lookOf(state, handle) : unseen,
376
+ interactive: part === 'hit',
377
+ });
378
+ };
379
+ const indices = Array.from({ length: arcSegments }, (_, index) => index);
380
+ return group({
381
+ key: `${key}:${handle}`,
382
+ rotation: fixed(ontoPlane[axis]),
383
+ children: [
384
+ ...indices.map((index) => segment(index, 'line')),
385
+ ...indices.map((index) => segment(index, 'hit')),
386
+ ],
387
+ });
388
+ };
389
+
390
+ /** Drei's `ScalingSphere`: a ball 1/22.5 of the size, 1.2 sizes out along its axis. */
391
+ const sphereNode = (key: string, state: Pivot, axis: Axis): SceneNode =>
392
+ mesh({
393
+ key: `${key}:sphere-${axis}`,
394
+ position: along(axis, state.scale * 1.2),
395
+ geometry: sphere({ radius: state.scale / 22.5, widthSegments: 12, heightSegments: 12 }),
396
+ material: lookOf(state, `sphere-${axis}`),
397
+ interactive: true,
398
+ });
399
+
400
+ const axesOf: ReadonlyArray<Axis> = ['x', 'y', 'z'];
401
+
402
+ /**
403
+ * The handles for `placement`: a group at the object, turned with it (and not scaled),
404
+ * its handles keyed `${key}:arrow-x`, `${key}:slider-z`, `${key}:rotator-y`,
405
+ * `${key}:sphere-x` and so on, each pickable.
406
+ */
407
+ export const pivotNodes = (key: string, state: Pivot, placement: Placement): SceneNode =>
408
+ group({
409
+ key,
410
+ position: placement.position,
411
+ rotation: fixed(placement.rotation),
412
+ children: [
413
+ ...(state.disableAxes ? [] : axesOf.flatMap((axis) => arrowNodes(key, state, axis))),
414
+ ...(state.disableSliders ? [] : axesOf.map((axis) => sliderNode(key, state, axis))),
415
+ ...(state.disableRotations ? [] : axesOf.map((axis) => rotatorNode(key, state, axis))),
416
+ ...(state.disableScaling ? [] : axesOf.map((axis) => sphereNode(key, state, axis))),
417
+ ],
418
+ });