@opsydyn/effect-three 0.3.0 → 0.6.4

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 (143) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +15 -1
  3. package/dist/{index-8cf8t6by.js → index-re3rrfwd.js} +2935 -104
  4. package/dist/index-re3rrfwd.js.map +56 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +557 -147
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/bodies.d.ts +20 -1
  10. package/dist/kernel/bodies.d.ts.map +1 -1
  11. package/dist/kernel/boxSelect.d.ts +22 -0
  12. package/dist/kernel/boxSelect.d.ts.map +1 -0
  13. package/dist/kernel/build.d.ts +165 -7
  14. package/dist/kernel/build.d.ts.map +1 -1
  15. package/dist/kernel/cameraShake.d.ts.map +1 -1
  16. package/dist/kernel/describeScene.d.ts +50 -0
  17. package/dist/kernel/describeScene.d.ts.map +1 -0
  18. package/dist/kernel/description.d.ts +223 -1
  19. package/dist/kernel/description.d.ts.map +1 -1
  20. package/dist/kernel/diff.d.ts +15 -0
  21. package/dist/kernel/diff.d.ts.map +1 -1
  22. package/dist/kernel/equality.d.ts +11 -1
  23. package/dist/kernel/equality.d.ts.map +1 -1
  24. package/dist/kernel/gizmo.d.ts +69 -0
  25. package/dist/kernel/gizmo.d.ts.map +1 -0
  26. package/dist/kernel/history.d.ts +61 -0
  27. package/dist/kernel/history.d.ts.map +1 -0
  28. package/dist/kernel/index.d.ts +19 -6
  29. package/dist/kernel/index.d.ts.map +1 -1
  30. package/dist/kernel/index.js +124 -3
  31. package/dist/kernel/index.js.map +1 -1
  32. package/dist/kernel/inspect.d.ts +85 -0
  33. package/dist/kernel/inspect.d.ts.map +1 -0
  34. package/dist/kernel/lineGeometry.d.ts +25 -0
  35. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  36. package/dist/kernel/lockstep.d.ts +114 -0
  37. package/dist/kernel/lockstep.d.ts.map +1 -0
  38. package/dist/kernel/loop.d.ts.map +1 -1
  39. package/dist/kernel/materials.d.ts.map +1 -1
  40. package/dist/kernel/paintOrder.d.ts +53 -0
  41. package/dist/kernel/paintOrder.d.ts.map +1 -0
  42. package/dist/kernel/physics.d.ts +39 -0
  43. package/dist/kernel/physics.d.ts.map +1 -1
  44. package/dist/kernel/pivot.d.ts +54 -0
  45. package/dist/kernel/pivot.d.ts.map +1 -0
  46. package/dist/kernel/pointBuffers.d.ts +17 -0
  47. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  48. package/dist/kernel/pointer.d.ts +31 -1
  49. package/dist/kernel/pointer.d.ts.map +1 -1
  50. package/dist/kernel/preview.d.ts +32 -0
  51. package/dist/kernel/preview.d.ts.map +1 -0
  52. package/dist/kernel/renderKit.d.ts +21 -1
  53. package/dist/kernel/renderKit.d.ts.map +1 -1
  54. package/dist/kernel/renderer.d.ts +6 -0
  55. package/dist/kernel/renderer.d.ts.map +1 -1
  56. package/dist/kernel/sceneChanges.d.ts +87 -0
  57. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  58. package/dist/kernel/sceneGraph.d.ts +9 -1
  59. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  60. package/dist/kernel/sceneIndex.d.ts +5 -0
  61. package/dist/kernel/sceneIndex.d.ts.map +1 -1
  62. package/dist/kernel/seeded.d.ts +9 -0
  63. package/dist/kernel/seeded.d.ts.map +1 -0
  64. package/dist/kernel/shots.d.ts +26 -0
  65. package/dist/kernel/shots.d.ts.map +1 -0
  66. package/dist/kernel/tape.d.ts +38 -0
  67. package/dist/kernel/tape.d.ts.map +1 -0
  68. package/dist/kernel/timeline.d.ts +60 -0
  69. package/dist/kernel/timeline.d.ts.map +1 -0
  70. package/dist/kernel/trail.d.ts +31 -0
  71. package/dist/kernel/trail.d.ts.map +1 -0
  72. package/dist/kernel/viewportGizmo.d.ts +25 -0
  73. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  74. package/dist/runtime/Inspector.d.ts +20 -0
  75. package/dist/runtime/Inspector.d.ts.map +1 -0
  76. package/dist/runtime/SceneFile.d.ts +462 -0
  77. package/dist/runtime/SceneFile.d.ts.map +1 -0
  78. package/dist/runtime/SceneSchema.d.ts +404 -0
  79. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  80. package/dist/runtime/errors.d.ts +9 -0
  81. package/dist/runtime/errors.d.ts.map +1 -1
  82. package/dist/runtime/index.d.ts +3 -1
  83. package/dist/runtime/index.d.ts.map +1 -1
  84. package/dist/webgl/index.d.ts.map +1 -1
  85. package/dist/webgl/index.js +228 -3
  86. package/dist/webgl/index.js.map +6 -4
  87. package/dist/webgl/line.d.ts +4 -0
  88. package/dist/webgl/line.d.ts.map +1 -0
  89. package/dist/webgl/points.d.ts +4 -0
  90. package/dist/webgl/points.d.ts.map +1 -0
  91. package/dist/webgpu/index.d.ts.map +1 -1
  92. package/dist/webgpu/index.js +206 -26
  93. package/dist/webgpu/index.js.map +6 -4
  94. package/dist/webgpu/line.d.ts +4 -0
  95. package/dist/webgpu/line.d.ts.map +1 -0
  96. package/dist/webgpu/points.d.ts +4 -0
  97. package/dist/webgpu/points.d.ts.map +1 -0
  98. package/package.json +4 -2
  99. package/src/kernel/bodies.ts +299 -17
  100. package/src/kernel/boxSelect.ts +73 -0
  101. package/src/kernel/build.ts +374 -1
  102. package/src/kernel/cameraShake.ts +3 -13
  103. package/src/kernel/describeScene.ts +224 -0
  104. package/src/kernel/description.ts +243 -0
  105. package/src/kernel/diff.ts +39 -0
  106. package/src/kernel/equality.ts +123 -0
  107. package/src/kernel/gizmo.ts +478 -0
  108. package/src/kernel/history.ts +118 -0
  109. package/src/kernel/index.ts +110 -1
  110. package/src/kernel/inspect.ts +214 -0
  111. package/src/kernel/lineGeometry.ts +75 -0
  112. package/src/kernel/lockstep.ts +237 -0
  113. package/src/kernel/loop.ts +7 -0
  114. package/src/kernel/materials.ts +3 -0
  115. package/src/kernel/paintOrder.ts +274 -0
  116. package/src/kernel/physics.ts +86 -1
  117. package/src/kernel/pivot.ts +418 -0
  118. package/src/kernel/pointBuffers.ts +115 -0
  119. package/src/kernel/pointer.ts +98 -17
  120. package/src/kernel/preview.ts +467 -0
  121. package/src/kernel/renderKit.ts +23 -1
  122. package/src/kernel/renderer.ts +6 -0
  123. package/src/kernel/sceneChanges.ts +265 -0
  124. package/src/kernel/sceneGraph.ts +354 -11
  125. package/src/kernel/sceneIndex.ts +8 -0
  126. package/src/kernel/seeded.ts +18 -0
  127. package/src/kernel/shots.ts +167 -0
  128. package/src/kernel/tape.ts +124 -0
  129. package/src/kernel/timeline.ts +93 -0
  130. package/src/kernel/trail.ts +170 -0
  131. package/src/kernel/viewportGizmo.ts +194 -0
  132. package/src/runtime/Inspector.ts +43 -0
  133. package/src/runtime/SceneFile.ts +46 -0
  134. package/src/runtime/SceneSchema.ts +319 -0
  135. package/src/runtime/errors.ts +6 -0
  136. package/src/runtime/index.ts +3 -1
  137. package/src/webgl/index.ts +4 -0
  138. package/src/webgl/line.ts +66 -0
  139. package/src/webgl/points.ts +205 -0
  140. package/src/webgpu/index.ts +4 -0
  141. package/src/webgpu/line.ts +67 -0
  142. package/src/webgpu/points.ts +193 -0
  143. package/dist/index-8cf8t6by.js.map +0 -41
@@ -5,10 +5,15 @@
5
5
  * @module
6
6
  */
7
7
  import type {
8
+ Asset,
8
9
  Animation,
9
10
  Behave,
10
11
  CameraShake,
11
12
  Detail,
13
+ LineNode,
14
+ Edges,
15
+ Trail,
16
+ PointLook,
12
17
  Body,
13
18
  BodyRef,
14
19
  GeometryRef,
@@ -573,6 +578,103 @@ export const labelMaterialEquals = (a: MaterialRef | null, b: MaterialRef | null
573
578
  a === b || (a !== null && b !== null && materialRefEquals(a, b));
574
579
 
575
580
  /** Shader grid equality, apart from its position and rotation. */
581
+ /** Points' look equality: the same kind with the same settings and points. */
582
+ export const pointLookEquals = (a: PointLook, b: PointLook): boolean => {
583
+ if (a === b) return true;
584
+ switch (a._tag) {
585
+ case 'Dots':
586
+ return (
587
+ b._tag === 'Dots' &&
588
+ a.positions.length === b.positions.length &&
589
+ a.positions.every((point, index) => vec3Equals(point, b.positions[index] ?? point)) &&
590
+ numbersEqual(a.colors, b.colors) &&
591
+ numbersEqual(a.sizes, b.sizes) &&
592
+ Object.is(a.color, b.color) &&
593
+ Object.is(a.size, b.size) &&
594
+ a.sizeAttenuation === b.sizeAttenuation &&
595
+ a.round === b.round &&
596
+ Object.is(a.opacity, b.opacity) &&
597
+ a.transparent === b.transparent
598
+ );
599
+ case 'Sparkles':
600
+ return (
601
+ b._tag === 'Sparkles' &&
602
+ a.count === b.count &&
603
+ Object.is(a.speed, b.speed) &&
604
+ Object.is(a.opacity, b.opacity) &&
605
+ vec3Equals(a.scale, b.scale) &&
606
+ Object.is(a.size, b.size) &&
607
+ Object.is(a.color, b.color) &&
608
+ vec3Equals(a.noise, b.noise) &&
609
+ Object.is(a.seed, b.seed)
610
+ );
611
+ case 'Stars':
612
+ return (
613
+ b._tag === 'Stars' &&
614
+ Object.is(a.radius, b.radius) &&
615
+ Object.is(a.depth, b.depth) &&
616
+ a.count === b.count &&
617
+ Object.is(a.factor, b.factor) &&
618
+ Object.is(a.saturation, b.saturation) &&
619
+ a.fade === b.fade &&
620
+ Object.is(a.speed, b.speed) &&
621
+ Object.is(a.seed, b.seed)
622
+ );
623
+ }
624
+ };
625
+
626
+ /** Trail equality: every setting. */
627
+ export const trailEquals = (a: Trail | null, b: Trail | null): boolean =>
628
+ a === b ||
629
+ (a !== null &&
630
+ b !== null &&
631
+ Object.is(a.width, b.width) &&
632
+ Object.is(a.length, b.length) &&
633
+ Object.is(a.decay, b.decay) &&
634
+ a.local === b.local &&
635
+ Object.is(a.stride, b.stride) &&
636
+ Object.is(a.interval, b.interval) &&
637
+ Object.is(a.color, b.color) &&
638
+ a.taper === b.taper &&
639
+ a.fade === b.fade);
640
+
641
+ /** Edge lines' equality: every setting. */
642
+ export const edgesEquals = (a: Edges | null, b: Edges | null): boolean =>
643
+ a === b ||
644
+ (a !== null &&
645
+ b !== null &&
646
+ Object.is(a.threshold, b.threshold) &&
647
+ Object.is(a.color, b.color) &&
648
+ Object.is(a.width, b.width) &&
649
+ a.worldUnits === b.worldUnits &&
650
+ a.dashed === b.dashed &&
651
+ Object.is(a.dashSize, b.dashSize) &&
652
+ Object.is(a.gapSize, b.gapSize) &&
653
+ Object.is(a.opacity, b.opacity) &&
654
+ a.transparent === b.transparent);
655
+
656
+ /** A line's points and look: everything but where it is. */
657
+ export const lineLookEquals = (a: LineNode, b: LineNode): boolean =>
658
+ a.segments === b.segments &&
659
+ Object.is(a.color, b.color) &&
660
+ Object.is(a.width, b.width) &&
661
+ a.worldUnits === b.worldUnits &&
662
+ a.dashed === b.dashed &&
663
+ Object.is(a.dashSize, b.dashSize) &&
664
+ Object.is(a.gapSize, b.gapSize) &&
665
+ Object.is(a.dashScale, b.dashScale) &&
666
+ Object.is(a.dashOffset, b.dashOffset) &&
667
+ Object.is(a.opacity, b.opacity) &&
668
+ a.transparent === b.transparent &&
669
+ (a.points === b.points ||
670
+ (a.points.length === b.points.length &&
671
+ a.points.every((point, index) => vec3Equals(point, b.points[index] ?? point)))) &&
672
+ (a.vertexColors === b.vertexColors ||
673
+ (a.vertexColors !== null &&
674
+ b.vertexColors !== null &&
675
+ a.vertexColors.length === b.vertexColors.length &&
676
+ a.vertexColors.every((color, index) => Object.is(color, b.vertexColors?.[index]))));
677
+
576
678
  export const shaderGridLookEquals = (a: ShaderGridNode, b: ShaderGridNode): boolean =>
577
679
  Object.is(a.size[0], b.size[0]) &&
578
680
  Object.is(a.size[1], b.size[1]) &&
@@ -589,6 +691,18 @@ export const shaderGridLookEquals = (a: ShaderGridNode, b: ShaderGridNode): bool
589
691
  Object.is(a.fadeFrom, b.fadeFrom) &&
590
692
  a.side === b.side;
591
693
 
694
+ const assetEquals = (a: Asset, b: Asset | undefined): boolean =>
695
+ b !== undefined &&
696
+ a.kind === b.kind &&
697
+ a.id === b.id &&
698
+ a.url === b.url &&
699
+ a.levels.length === b.levels.length &&
700
+ a.levels.every((level, i) => level === b.levels[i]);
701
+
702
+ /** Asset list equality: the same kinds, ids, urls and levels, in the same order. */
703
+ export const assetsEqual = (a: ReadonlyArray<Asset>, b: ReadonlyArray<Asset>): boolean =>
704
+ a === b || (a.length === b.length && a.every((asset, i) => assetEquals(asset, b[i])));
705
+
592
706
  /** Physics world equality. */
593
707
  export const physicsEquals = (a: Physics | null, b: Physics | null): boolean =>
594
708
  a === b ||
@@ -598,6 +712,15 @@ export const physicsEquals = (a: Physics | null, b: Physics | null): boolean =>
598
712
  a.paused === b.paused &&
599
713
  Object.is(a.timeStep, b.timeStep) &&
600
714
  a.interpolate === b.interpolate &&
715
+ a.until === b.until &&
716
+ a.run === b.run &&
717
+ a.snapshotAt === b.snapshotAt &&
718
+ a.keep === b.keep &&
719
+ (a.from === b.from ||
720
+ (a.from !== null &&
721
+ b.from !== null &&
722
+ a.from.step === b.from.step &&
723
+ a.from.snapshot === b.from.snapshot)) &&
601
724
  a.debug === b.debug &&
602
725
  jointsEqual(a.joints, b.joints));
603
726
 
@@ -0,0 +1,478 @@
1
+ /**
2
+ * A transform gizmo as data, after three.js's `TransformControls` (0.186.1,
3
+ * `examples/jsm/controls/TransformControls.js`), which Drei's `TransformControls` wraps.
4
+ * three's controls listen to the canvas and move the object themselves; here, as for the
5
+ * camera (ADR-006), the gizmo is pure: `gizmoNodes` describes its handles from the Model,
6
+ * a press on one (`pressGizmo`) and the drag rays after it (`dragGizmo`) return the next
7
+ * placement, and the Model keeps both. Every move is a Message, so it can be undone,
8
+ * replayed and tested without a browser.
9
+ *
10
+ * The maths is three's: a single axis is dragged on the plane through it that best faces
11
+ * the eye; a plane handle on that plane; the centre on a plane facing the camera.
12
+ * Rotation turns by the drag along `axis × eye`, at three's speed (20 over the camera's
13
+ * distance). Scale is always in the object's own axes. Handles keep their size on
14
+ * screen, as three's do, and hide when they point straight at the eye.
15
+ *
16
+ * Placements are in the object's parent's space, which is taken to be the world: a gizmo
17
+ * for a node at the top of the scene, or in an unrotated, unscaled group.
18
+ *
19
+ * @module
20
+ */
21
+ import { Euler, Quaternion, Vector3 } from 'three';
22
+
23
+ import { basic, box, cone, cylinder, fixed, group, mesh, plane, sphere, torus } from './build.ts';
24
+ import type { Camera, SceneNode, Vec3 } from './description.ts';
25
+ import { type PlaneEquation, pointOnPlane, type Ray } from './pointer.ts';
26
+
27
+ export type GizmoMode = 'translate' | 'rotate' | 'scale';
28
+ /** A handle: an axis, the plane of two, or all three (the centre). */
29
+ export type GizmoHandle = 'x' | 'y' | 'z' | 'xy' | 'yz' | 'xz' | 'xyz';
30
+ export type GizmoSpace = 'world' | 'local';
31
+
32
+ /** Where an object is: its position, Euler angles (radians) and scale. */
33
+ export type Placement = {
34
+ readonly position: Vec3;
35
+ readonly rotation: Vec3;
36
+ readonly scale: Vec3;
37
+ };
38
+
39
+ /** A drag in progress: the handle, where it began, and the plane it moves on. */
40
+ export type GizmoGrab = {
41
+ readonly handle: GizmoHandle;
42
+ readonly start: Placement;
43
+ /** The press point on the plane, from the object's position. */
44
+ readonly from: Vec3;
45
+ readonly plane: PlaneEquation;
46
+ /** From the object towards the eye, as the press saw it. */
47
+ readonly eye: Vec3;
48
+ /** The eye's distance, for rotation speed. */
49
+ readonly distance: number;
50
+ };
51
+
52
+ export type Gizmo = {
53
+ readonly mode: GizmoMode;
54
+ readonly space: GizmoSpace;
55
+ /** On-screen size, 1 as three's. */
56
+ readonly size: number;
57
+ /** Rounds moves, turns (radians) and scales to these steps, or not when `null`. */
58
+ readonly translationSnap: number | null;
59
+ readonly rotationSnap: number | null;
60
+ readonly scaleSnap: number | null;
61
+ readonly showX: boolean;
62
+ readonly showY: boolean;
63
+ readonly showZ: boolean;
64
+ /** The handle under the pointer, drawn highlighted. */
65
+ readonly hovered: GizmoHandle | null;
66
+ readonly grab: GizmoGrab | null;
67
+ };
68
+
69
+ /** A gizmo with Drei's and three's defaults: translating, in world space, size 1, no snaps. */
70
+ export const gizmo = (options: Partial<Omit<Gizmo, 'hovered' | 'grab'>> = {}): Gizmo => ({
71
+ mode: options.mode ?? 'translate',
72
+ space: options.space ?? 'world',
73
+ size: options.size ?? 1,
74
+ translationSnap: options.translationSnap ?? null,
75
+ rotationSnap: options.rotationSnap ?? null,
76
+ scaleSnap: options.scaleSnap ?? null,
77
+ showX: options.showX ?? true,
78
+ showY: options.showY ?? true,
79
+ showZ: options.showZ ?? true,
80
+ hovered: null,
81
+ grab: null,
82
+ });
83
+
84
+ const axes = {
85
+ x: new Vector3(1, 0, 0),
86
+ y: new Vector3(0, 1, 0),
87
+ z: new Vector3(0, 0, 1),
88
+ } as const;
89
+
90
+ const turnOf = (rotation: Vec3): Quaternion =>
91
+ new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2]));
92
+
93
+ /** The space handles are in: scale is always the object's own (three's rule). */
94
+ const spaceOf = (state: Gizmo, handle: GizmoHandle | null): GizmoSpace => {
95
+ if (state.mode === 'scale') return 'local';
96
+ if (handle === 'xyz') return 'world';
97
+ return state.space;
98
+ };
99
+
100
+ /** An axis in world space: the object's own when the space is local. */
101
+ const axisIn = (name: 'x' | 'y' | 'z', space: GizmoSpace, rotation: Vec3): Vector3 =>
102
+ space === 'local' ? axes[name].clone().applyQuaternion(turnOf(rotation)) : axes[name].clone();
103
+
104
+ const toVec3 = (vector: Vector3): Vec3 => [vector.x, vector.y, vector.z];
105
+
106
+ /**
107
+ * three's `TransformControlsPlane`: the plane the handle drags on, through `center`. An
108
+ * axis: its plane facing the eye; a plane handle: that plane; the centre and every
109
+ * rotation: the plane facing the camera.
110
+ */
111
+ const dragPlane = (
112
+ state: Gizmo,
113
+ handle: GizmoHandle,
114
+ start: Placement,
115
+ eye: Vector3,
116
+ facingCamera: Vector3,
117
+ ): PlaneEquation => {
118
+ const space = spaceOf(state, handle);
119
+ const along = (name: 'x' | 'y' | 'z') => axisIn(name, space, start.rotation);
120
+ const facing = (axis: Vector3) => axis.clone().cross(eye.clone().cross(axis));
121
+ const normals: Record<GizmoHandle, () => Vector3> = {
122
+ x: () => facing(along('x')),
123
+ y: () => facing(along('y')),
124
+ z: () => facing(along('z')),
125
+ xy: () => along('z'),
126
+ yz: () => along('x'),
127
+ xz: () => along('y'),
128
+ xyz: () => facingCamera.clone(),
129
+ };
130
+ const normal = state.mode === 'rotate' ? facingCamera.clone() : normals[handle]();
131
+ if (normal.lengthSq() < 1e-12) normal.copy(eye);
132
+ normal.normalize();
133
+ const center = new Vector3(...start.position);
134
+ return { normal: toVec3(normal), constant: -normal.dot(center) };
135
+ };
136
+
137
+ /** Where a ray meets the grab's plane, from the object's starting position. */
138
+ const onPlane = (grab: GizmoGrab, ray: Ray): Vector3 | undefined => {
139
+ const point = pointOnPlane(ray, grab.plane);
140
+ if (point === undefined) return undefined;
141
+ return new Vector3(...point).sub(new Vector3(...grab.start.position));
142
+ };
143
+
144
+ /** From the camera's target back to the camera: the way the camera's screen faces. */
145
+ const backwards = (camera: Camera): Vector3 => {
146
+ const back = new Vector3(...camera.position).sub(new Vector3(...camera.lookAt));
147
+ return back.lengthSq() > 1e-12 ? back.normalize() : new Vector3(0, 0, 1);
148
+ };
149
+
150
+ /**
151
+ * Takes hold of `handle` on a press along `ray`, seen from `camera` (the scene's, as the
152
+ * Model holds it): the gizmo remembers where the drag began.
153
+ */
154
+ export const pressGizmo = (
155
+ state: Gizmo,
156
+ placement: Placement,
157
+ handle: GizmoHandle,
158
+ ray: Ray,
159
+ camera: Camera,
160
+ ): Gizmo => {
161
+ const center = new Vector3(...placement.position);
162
+ const facingCamera = backwards(camera);
163
+ // three's eye: towards a perspective camera, or along an orthographic one's view.
164
+ const towardsEye = new Vector3(...camera.position).sub(center);
165
+ const distance = towardsEye.length();
166
+ const eye =
167
+ camera._tag === 'Orthographic' || distance < 1e-9
168
+ ? facingCamera.clone()
169
+ : towardsEye.normalize();
170
+ const plane = dragPlane(state, handle, placement, eye, facingCamera);
171
+ const point = pointOnPlane(ray, plane);
172
+ if (point === undefined) return state;
173
+ const from = toVec3(new Vector3(...point).sub(center));
174
+ return { ...state, grab: { handle, start: placement, from, plane, eye: toVec3(eye), distance } };
175
+ };
176
+
177
+ /** Lets go: the drag ends where it is. */
178
+ export const releaseGizmo = (state: Gizmo): Gizmo => ({ ...state, grab: null });
179
+
180
+ /** The handle under the pointer, or none. */
181
+ export const hoverGizmo = (state: Gizmo, handle: GizmoHandle | null): Gizmo =>
182
+ state.hovered === handle ? state : { ...state, hovered: handle };
183
+
184
+ const snapped = (value: number, step: number | null): number =>
185
+ step === null ? value : Math.round(value / step) * step;
186
+
187
+ const includes = (handle: GizmoHandle, axis: 'x' | 'y' | 'z'): boolean => handle.includes(axis);
188
+
189
+ /** three's translate: the drag's offset, kept to the handle's axes, then snapped. */
190
+ const translated = (state: Gizmo, grab: GizmoGrab, end: Vector3): Placement => {
191
+ const space = spaceOf(state, grab.handle);
192
+ const offset = end.clone().sub(new Vector3(...grab.from));
193
+ const turn = turnOf(grab.start.rotation);
194
+ if (space === 'local') offset.applyQuaternion(turn.clone().invert());
195
+ if (!includes(grab.handle, 'x')) offset.x = 0;
196
+ if (!includes(grab.handle, 'y')) offset.y = 0;
197
+ if (!includes(grab.handle, 'z')) offset.z = 0;
198
+ if (space === 'local') {
199
+ // Snapped along the object's own axes, as three snaps local moves.
200
+ const local = offset.add(
201
+ new Vector3(...grab.start.position).applyQuaternion(turn.clone().invert()),
202
+ );
203
+ if (includes(grab.handle, 'x')) local.x = snapped(local.x, state.translationSnap);
204
+ if (includes(grab.handle, 'y')) local.y = snapped(local.y, state.translationSnap);
205
+ if (includes(grab.handle, 'z')) local.z = snapped(local.z, state.translationSnap);
206
+ return { ...grab.start, position: toVec3(local.applyQuaternion(turn)) };
207
+ }
208
+ const position = offset.add(new Vector3(...grab.start.position));
209
+ if (includes(grab.handle, 'x')) position.x = snapped(position.x, state.translationSnap);
210
+ if (includes(grab.handle, 'y')) position.y = snapped(position.y, state.translationSnap);
211
+ if (includes(grab.handle, 'z')) position.z = snapped(position.z, state.translationSnap);
212
+ return { ...grab.start, position: toVec3(position) };
213
+ };
214
+
215
+ /** three's scale: the press and drag points' ratio, in the object's own axes. */
216
+ const scaled = (state: Gizmo, grab: GizmoGrab, end: Vector3): Placement => {
217
+ const from = new Vector3(...grab.from);
218
+ const factor = new Vector3(1, 1, 1);
219
+ if (grab.handle === 'xyz') {
220
+ const ratio = (end.length() / Math.max(from.length(), 1e-9)) * (end.dot(from) < 0 ? -1 : 1);
221
+ factor.setScalar(ratio);
222
+ } else {
223
+ const inverse = turnOf(grab.start.rotation).invert();
224
+ const a = from.clone().applyQuaternion(inverse);
225
+ const b = end.clone().applyQuaternion(inverse);
226
+ factor.set(
227
+ includes(grab.handle, 'x') ? b.x / a.x : 1,
228
+ includes(grab.handle, 'y') ? b.y / a.y : 1,
229
+ includes(grab.handle, 'z') ? b.z / a.z : 1,
230
+ );
231
+ }
232
+ const step = state.scaleSnap;
233
+ const each = (index: 0 | 1 | 2, axis: 'x' | 'y' | 'z'): number => {
234
+ const value = grab.start.scale[index] * factor.getComponent(index);
235
+ if (step === null || !includes(grab.handle, axis)) return value;
236
+ return snapped(value, step) || step;
237
+ };
238
+ return { ...grab.start, scale: [each(0, 'x'), each(1, 'y'), each(2, 'z')] };
239
+ };
240
+
241
+ /** three's rotate: the drag along `axis × eye`, at its speed; turned about the axis. */
242
+ const rotated = (state: Gizmo, grab: GizmoGrab, end: Vector3): Placement => {
243
+ if (grab.handle !== 'x' && grab.handle !== 'y' && grab.handle !== 'z') return grab.start;
244
+ const space = spaceOf(state, grab.handle);
245
+ const offset = end.clone().sub(new Vector3(...grab.from));
246
+ const speed = 20 / Math.max(grab.distance, 1e-9);
247
+ const axis = axisIn(grab.handle, space, grab.start.rotation);
248
+ const across = axis.clone().cross(new Vector3(...grab.eye));
249
+ const angle = snapped(
250
+ across.lengthSq() < 1e-12 ? 0 : offset.dot(across.normalize()) * speed,
251
+ state.rotationSnap,
252
+ );
253
+ const start = turnOf(grab.start.rotation);
254
+ const turn =
255
+ space === 'local'
256
+ ? start.clone().multiply(new Quaternion().setFromAxisAngle(axes[grab.handle], angle))
257
+ : new Quaternion().setFromAxisAngle(axes[grab.handle], angle).multiply(start);
258
+ const euler = new Euler().setFromQuaternion(turn.normalize());
259
+ return { ...grab.start, rotation: [euler.x, euler.y, euler.z] };
260
+ };
261
+
262
+ /** The placement a drag ray gives, or `undefined` while nothing is held. */
263
+ export const dragGizmo = (state: Gizmo, ray: Ray): Placement | undefined => {
264
+ const grab = state.grab;
265
+ if (grab === null) return undefined;
266
+ const end = onPlane(grab, ray);
267
+ if (end === undefined) return grab.start;
268
+ if (state.mode === 'translate') return translated(state, grab, end);
269
+ if (state.mode === 'scale') return scaled(state, grab, end);
270
+ return rotated(state, grab, end);
271
+ };
272
+
273
+ /**
274
+ * `other` moved as a drag moved one thing from `from` to `to`, for a gizmo over several
275
+ * selected things: the same offset, the same turn (in world space) about its own centre,
276
+ * and the same change of scale along its own axes, as editors' "individual origins".
277
+ */
278
+ export const movedAlike = (from: Placement, to: Placement, other: Placement): Placement => {
279
+ const turn = turnOf(to.rotation).multiply(turnOf(from.rotation).invert());
280
+ const turned = new Euler().setFromQuaternion(turn.multiply(turnOf(other.rotation)).normalize());
281
+ const ratio = (axis: 0 | 1 | 2): number =>
282
+ from.scale[axis] === 0 ? 1 : to.scale[axis] / from.scale[axis];
283
+ const shifted = (axis: 0 | 1 | 2): number =>
284
+ other.position[axis] + to.position[axis] - from.position[axis];
285
+ return {
286
+ position: [shifted(0), shifted(1), shifted(2)],
287
+ rotation: [turned.x, turned.y, turned.z],
288
+ scale: [other.scale[0] * ratio(0), other.scale[1] * ratio(1), other.scale[2] * ratio(2)],
289
+ };
290
+ };
291
+
292
+ // DRAWING
293
+
294
+ const colours = {
295
+ x: 0xff3653,
296
+ y: 0x8adb00,
297
+ z: 0x2c8fff,
298
+ xy: 0x2c8fff,
299
+ yz: 0xff3653,
300
+ xz: 0x8adb00,
301
+ } as const;
302
+ const highlight = 0xffff00;
303
+ /** three's `AXIS_HIDE_THRESHOLD`: an axis this close to the eye is hidden. */
304
+ const hideThreshold = 0.99;
305
+
306
+ /**
307
+ * three's on-screen size: the camera's distance times its view's spread, a quarter of
308
+ * that per unit of `size`. An orthographic camera is taken as 480 pixels tall.
309
+ */
310
+ const screenScale = (state: Gizmo, at: Vec3, camera: Camera): number => {
311
+ if (camera._tag === 'Orthographic') return ((480 / camera.zoom) * state.size) / 4;
312
+ const distance = new Vector3(...camera.position).distanceTo(new Vector3(...at));
313
+ const spread = Math.min(1.9 * Math.tan((Math.PI * camera.fov) / 360), 7);
314
+ return (distance * spread * state.size) / 4;
315
+ };
316
+
317
+ /** The handle a hit names, if it is one of this gizmo's. */
318
+ export const gizmoHandleOf = (key: string, hit: string): GizmoHandle | undefined => {
319
+ const prefix = `${key}:`;
320
+ if (!hit.startsWith(prefix)) return undefined;
321
+ const name = hit.slice(prefix.length).split(':')[0];
322
+ const handles: ReadonlyArray<GizmoHandle> = ['x', 'y', 'z', 'xy', 'yz', 'xz', 'xyz'];
323
+ return handles.find((handle) => handle === name);
324
+ };
325
+
326
+ const look = (state: Gizmo, handle: GizmoHandle, colour: number) =>
327
+ basic({
328
+ color: state.grab?.handle === handle || state.hovered === handle ? highlight : colour,
329
+ transparent: true,
330
+ opacity: state.grab !== null && state.grab.handle !== handle ? 0.25 : 0.9,
331
+ // Over everything, as three's gizmo: seen through the object it moves.
332
+ depthTest: false,
333
+ });
334
+
335
+ /** Turns +y onto each axis, for the arrows and stems, which are drawn along y. */
336
+ const pointAlong = { x: [0, 0, -Math.PI / 2], y: [0, 0, 0], z: [Math.PI / 2, 0, 0] } as const;
337
+ /** Where each arrow's tip is: half a unit along its axis. */
338
+ const tips = { x: [0.5, 0, 0], y: [0, 0.5, 0], z: [0, 0, 0.5] } as const satisfies Record<
339
+ 'x' | 'y' | 'z',
340
+ Vec3
341
+ >;
342
+ /** Turns each ring (in xy, round z) round its axis. */
343
+ const ringRound = { x: [0, Math.PI / 2, 0], y: [Math.PI / 2, 0, 0], z: [0, 0, 0] } as const;
344
+
345
+ type Axis = 'x' | 'y' | 'z';
346
+
347
+ /** A rotation handle: a ring round the axis. */
348
+ const ring = (key: string, state: Gizmo, axis: Axis): ReadonlyArray<SceneNode> => [
349
+ mesh({
350
+ key: `${key}:${axis}`,
351
+ rotation: fixed([...ringRound[axis]]),
352
+ geometry: torus({ radius: 0.5, tube: 0.02, radialSegments: 6, tubularSegments: 48 }),
353
+ material: look(state, axis, colours[axis]),
354
+ interactive: true,
355
+ }),
356
+ ];
357
+
358
+ /** A translate or scale handle: a stem along the axis, an arrowhead or a box at its end. */
359
+ const arrow = (key: string, state: Gizmo, axis: Axis): ReadonlyArray<SceneNode> => {
360
+ const along = pointAlong[axis];
361
+ const tipAt = tips[axis];
362
+ const material = look(state, axis, colours[axis]);
363
+ return [
364
+ mesh({
365
+ key: `${key}:${axis}:stem`,
366
+ position: [tipAt[0] / 2, tipAt[1] / 2, tipAt[2] / 2],
367
+ rotation: fixed([...along]),
368
+ geometry: cylinder({ radiusTop: 0.012, radiusBottom: 0.012, height: 0.5 }),
369
+ material,
370
+ interactive: true,
371
+ }),
372
+ mesh({
373
+ key: `${key}:${axis}:tip`,
374
+ position: tipAt,
375
+ rotation: fixed([...along]),
376
+ geometry:
377
+ state.mode === 'scale'
378
+ ? box({ width: 0.08, height: 0.08, depth: 0.08 })
379
+ : cone({ radius: 0.04, height: 0.12, radialSegments: 12 }),
380
+ material,
381
+ interactive: true,
382
+ }),
383
+ ];
384
+ };
385
+
386
+ const axisHandles = (
387
+ key: string,
388
+ state: Gizmo,
389
+ visible: (axis: Axis) => boolean,
390
+ ): ReadonlyArray<SceneNode> =>
391
+ (['x', 'y', 'z'] as const)
392
+ .filter(visible)
393
+ .flatMap((axis) =>
394
+ state.mode === 'rotate' ? ring(key, state, axis) : arrow(key, state, axis),
395
+ );
396
+
397
+ /** The plane handles of translate and scale: small squares between two axes. */
398
+ const planeHandles = (key: string, state: Gizmo): ReadonlyArray<SceneNode> => {
399
+ if (state.mode === 'rotate') return [];
400
+ const squares = [
401
+ {
402
+ handle: 'xy',
403
+ at: [0.15, 0.15, 0] as Vec3,
404
+ turn: [0, 0, 0] as Vec3,
405
+ shown: state.showX && state.showY,
406
+ },
407
+ {
408
+ handle: 'yz',
409
+ at: [0, 0.15, 0.15] as Vec3,
410
+ turn: [0, Math.PI / 2, 0] as Vec3,
411
+ shown: state.showY && state.showZ,
412
+ },
413
+ {
414
+ handle: 'xz',
415
+ at: [0.15, 0, 0.15] as Vec3,
416
+ turn: [-Math.PI / 2, 0, 0] as Vec3,
417
+ shown: state.showX && state.showZ,
418
+ },
419
+ ] as const;
420
+ return squares
421
+ .filter((square) => square.shown)
422
+ .map((square) =>
423
+ mesh({
424
+ key: `${key}:${square.handle}`,
425
+ position: square.at,
426
+ rotation: fixed([...square.turn]),
427
+ geometry: plane({ width: 0.12, height: 0.12 }),
428
+ material: { ...look(state, square.handle, colours[square.handle]), side: 'double' },
429
+ interactive: true,
430
+ }),
431
+ );
432
+ };
433
+
434
+ /**
435
+ * The gizmo's handles for `placement`, seen from `camera`: a group at the object's
436
+ * position, turned with it in local space (and always when scaling), its handles keyed
437
+ * `${key}:x`, `${key}:xy`, `${key}:xyz` and so on, each pickable. Highlights the hovered
438
+ * or held handle in yellow, as three does.
439
+ */
440
+ export const gizmoNodes = (
441
+ key: string,
442
+ state: Gizmo,
443
+ placement: Placement,
444
+ camera: Camera,
445
+ ): SceneNode => {
446
+ const space = spaceOf(state, state.grab?.handle ?? null);
447
+ const scale = screenScale(state, placement.position, camera);
448
+ const eye = new Vector3(...camera.position).sub(new Vector3(...placement.position)).normalize();
449
+ const shown = { x: state.showX, y: state.showY, z: state.showZ };
450
+ const visible = (axis: 'x' | 'y' | 'z'): boolean =>
451
+ shown[axis] &&
452
+ (state.mode === 'rotate' ||
453
+ state.grab?.handle === axis ||
454
+ Math.abs(axisIn(axis, space, placement.rotation).dot(eye)) <= hideThreshold);
455
+ return group({
456
+ key,
457
+ position: placement.position,
458
+ rotation: fixed(space === 'local' ? placement.rotation : [0, 0, 0]),
459
+ scale: [scale, scale, scale],
460
+ children: [
461
+ ...axisHandles(key, state, visible),
462
+ ...planeHandles(key, state),
463
+ ...(state.mode === 'rotate'
464
+ ? []
465
+ : [
466
+ mesh({
467
+ key: `${key}:xyz`,
468
+ geometry:
469
+ state.mode === 'scale'
470
+ ? box({ width: 0.1, height: 0.1, depth: 0.1 })
471
+ : sphere({ radius: 0.06, widthSegments: 12, heightSegments: 8 }),
472
+ material: look(state, 'xyz', 0xffffff),
473
+ interactive: true,
474
+ }),
475
+ ]),
476
+ ],
477
+ });
478
+ };