@opsydyn/effect-three 0.3.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.
- package/CHANGELOG.md +15 -0
- package/README.md +12 -0
- package/dist/{index-8cf8t6by.js → index-h3vf50fn.js} +2113 -83
- package/dist/index-h3vf50fn.js.map +53 -0
- package/dist/index-ttwy0znv.js +145 -0
- package/dist/index-ttwy0znv.js.map +12 -0
- package/dist/index.js +488 -147
- package/dist/index.js.map +8 -6
- package/dist/kernel/boxSelect.d.ts +22 -0
- package/dist/kernel/boxSelect.d.ts.map +1 -0
- package/dist/kernel/build.d.ts +142 -6
- package/dist/kernel/build.d.ts.map +1 -1
- package/dist/kernel/cameraShake.d.ts.map +1 -1
- package/dist/kernel/describeScene.d.ts +50 -0
- package/dist/kernel/describeScene.d.ts.map +1 -0
- package/dist/kernel/description.d.ts +155 -1
- package/dist/kernel/description.d.ts.map +1 -1
- package/dist/kernel/diff.d.ts +15 -0
- package/dist/kernel/diff.d.ts.map +1 -1
- package/dist/kernel/equality.d.ts +9 -1
- package/dist/kernel/equality.d.ts.map +1 -1
- package/dist/kernel/gizmo.d.ts +69 -0
- package/dist/kernel/gizmo.d.ts.map +1 -0
- package/dist/kernel/history.d.ts +61 -0
- package/dist/kernel/history.d.ts.map +1 -0
- package/dist/kernel/index.d.ts +13 -2
- package/dist/kernel/index.d.ts.map +1 -1
- package/dist/kernel/index.js +106 -3
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/inspect.d.ts +85 -0
- package/dist/kernel/inspect.d.ts.map +1 -0
- package/dist/kernel/lineGeometry.d.ts +25 -0
- package/dist/kernel/lineGeometry.d.ts.map +1 -0
- package/dist/kernel/lockstep.d.ts +114 -0
- package/dist/kernel/lockstep.d.ts.map +1 -0
- package/dist/kernel/loop.d.ts.map +1 -1
- package/dist/kernel/materials.d.ts.map +1 -1
- package/dist/kernel/pivot.d.ts +54 -0
- package/dist/kernel/pivot.d.ts.map +1 -0
- package/dist/kernel/pointBuffers.d.ts +17 -0
- package/dist/kernel/pointBuffers.d.ts.map +1 -0
- package/dist/kernel/pointer.d.ts +12 -0
- package/dist/kernel/pointer.d.ts.map +1 -1
- package/dist/kernel/renderKit.d.ts +21 -1
- package/dist/kernel/renderKit.d.ts.map +1 -1
- package/dist/kernel/renderer.d.ts +6 -0
- package/dist/kernel/renderer.d.ts.map +1 -1
- package/dist/kernel/sceneChanges.d.ts +87 -0
- package/dist/kernel/sceneChanges.d.ts.map +1 -0
- package/dist/kernel/sceneGraph.d.ts +6 -0
- package/dist/kernel/sceneGraph.d.ts.map +1 -1
- package/dist/kernel/seeded.d.ts +9 -0
- package/dist/kernel/seeded.d.ts.map +1 -0
- package/dist/kernel/shots.d.ts +26 -0
- package/dist/kernel/shots.d.ts.map +1 -0
- package/dist/kernel/tape.d.ts +38 -0
- package/dist/kernel/tape.d.ts.map +1 -0
- package/dist/kernel/trail.d.ts +31 -0
- package/dist/kernel/trail.d.ts.map +1 -0
- package/dist/kernel/viewportGizmo.d.ts +25 -0
- package/dist/kernel/viewportGizmo.d.ts.map +1 -0
- package/dist/runtime/Inspector.d.ts +20 -0
- package/dist/runtime/Inspector.d.ts.map +1 -0
- package/dist/runtime/SceneFile.d.ts +448 -0
- package/dist/runtime/SceneFile.d.ts.map +1 -0
- package/dist/runtime/SceneSchema.d.ts +318 -0
- package/dist/runtime/SceneSchema.d.ts.map +1 -1
- package/dist/runtime/errors.d.ts +9 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/webgl/index.d.ts.map +1 -1
- package/dist/webgl/index.js +228 -3
- package/dist/webgl/index.js.map +6 -4
- package/dist/webgl/line.d.ts +4 -0
- package/dist/webgl/line.d.ts.map +1 -0
- package/dist/webgl/points.d.ts +4 -0
- package/dist/webgl/points.d.ts.map +1 -0
- package/dist/webgpu/index.d.ts.map +1 -1
- package/dist/webgpu/index.js +206 -26
- package/dist/webgpu/index.js.map +6 -4
- package/dist/webgpu/line.d.ts +4 -0
- package/dist/webgpu/line.d.ts.map +1 -0
- package/dist/webgpu/points.d.ts +4 -0
- package/dist/webgpu/points.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/kernel/boxSelect.ts +73 -0
- package/src/kernel/build.ts +332 -0
- package/src/kernel/cameraShake.ts +3 -13
- package/src/kernel/describeScene.ts +224 -0
- package/src/kernel/description.ts +175 -0
- package/src/kernel/diff.ts +39 -0
- package/src/kernel/equality.ts +101 -0
- package/src/kernel/gizmo.ts +478 -0
- package/src/kernel/history.ts +118 -0
- package/src/kernel/index.ts +97 -0
- package/src/kernel/inspect.ts +214 -0
- package/src/kernel/lineGeometry.ts +75 -0
- package/src/kernel/lockstep.ts +237 -0
- package/src/kernel/loop.ts +5 -0
- package/src/kernel/materials.ts +3 -0
- package/src/kernel/pivot.ts +418 -0
- package/src/kernel/pointBuffers.ts +115 -0
- package/src/kernel/pointer.ts +55 -12
- package/src/kernel/renderKit.ts +23 -1
- package/src/kernel/renderer.ts +6 -0
- package/src/kernel/sceneChanges.ts +265 -0
- package/src/kernel/sceneGraph.ts +319 -8
- package/src/kernel/seeded.ts +18 -0
- package/src/kernel/shots.ts +167 -0
- package/src/kernel/tape.ts +124 -0
- package/src/kernel/trail.ts +170 -0
- package/src/kernel/viewportGizmo.ts +194 -0
- package/src/runtime/Inspector.ts +43 -0
- package/src/runtime/SceneFile.ts +46 -0
- package/src/runtime/SceneSchema.ts +247 -0
- package/src/runtime/errors.ts +6 -0
- package/src/runtime/index.ts +3 -1
- package/src/webgl/index.ts +4 -0
- package/src/webgl/line.ts +66 -0
- package/src/webgl/points.ts +205 -0
- package/src/webgpu/index.ts +4 -0
- package/src/webgpu/line.ts +67 -0
- package/src/webgpu/points.ts +193 -0
- package/dist/index-8cf8t6by.js.map +0 -41
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo and branching history from an immutable Model (roadmap, "Beyond parity" 4). A
|
|
3
|
+
* Model's values are never changed in place, so keeping every one costs only the
|
|
4
|
+
* references: a history is a tree of them. Recording after an undo starts a new branch
|
|
5
|
+
* and keeps the old future as another, so nothing done is ever lost; redo follows the
|
|
6
|
+
* branch last taken, and `chooseBranch` or `jumpTo` take another.
|
|
7
|
+
*
|
|
8
|
+
* Entries are numbered in the order they were recorded, from 0 (the first value), and
|
|
9
|
+
* each names the entry it followed. Pure data with a schema (`SceneSchema.History`),
|
|
10
|
+
* so a history can be saved, sent or inspected like the rest of the Model. Use it as a
|
|
11
|
+
* namespace: `History.make(value)`, `History.record(history, next, 'Moved crate')`.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A value in the history: the entry it followed, and the one redo goes to from it. */
|
|
17
|
+
export type HistoryEntry<A> = {
|
|
18
|
+
readonly id: number;
|
|
19
|
+
/** The entry this one followed, `null` for the first. */
|
|
20
|
+
readonly parent: number | null;
|
|
21
|
+
/** A few words for the change that made it, for a list of steps. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly value: A;
|
|
24
|
+
/** The entry redo goes to from here: the branch last taken, `null` with none. */
|
|
25
|
+
readonly next: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type History<A> = {
|
|
29
|
+
/** Every value recorded, by id. */
|
|
30
|
+
readonly entries: ReadonlyArray<HistoryEntry<A>>;
|
|
31
|
+
/** The present value's id. */
|
|
32
|
+
readonly current: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A history holding `value` alone. */
|
|
36
|
+
export const make = <A>(value: A, label = 'Start'): History<A> => ({
|
|
37
|
+
entries: [{ id: 0, parent: null, label, value, next: null }],
|
|
38
|
+
current: 0,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const entryOf = <A>(state: History<A>, id: number): HistoryEntry<A> => {
|
|
42
|
+
const entry = state.entries[id];
|
|
43
|
+
// Ids are indices, made only here: one out of range is a broken history.
|
|
44
|
+
if (entry === undefined) throw new RangeError(`No history entry ${id}`);
|
|
45
|
+
return entry;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The present entry. */
|
|
49
|
+
export const presentEntry = <A>(state: History<A>): HistoryEntry<A> =>
|
|
50
|
+
entryOf(state, state.current);
|
|
51
|
+
|
|
52
|
+
/** The present value. */
|
|
53
|
+
export const present = <A>(state: History<A>): A => presentEntry(state).value;
|
|
54
|
+
|
|
55
|
+
const withNext = <A>(state: History<A>, id: number, next: number): History<A> => ({
|
|
56
|
+
...state,
|
|
57
|
+
entries: state.entries.map((entry) => (entry.id === id ? { ...entry, next } : entry)),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** `value` after the present, labelled: the new present, its own branch after an undo. */
|
|
61
|
+
export const record = <A>(state: History<A>, value: A, label: string): History<A> => {
|
|
62
|
+
const id = state.entries.length;
|
|
63
|
+
const grown: History<A> = {
|
|
64
|
+
entries: [...state.entries, { id, parent: state.current, label, value, next: null }],
|
|
65
|
+
current: id,
|
|
66
|
+
};
|
|
67
|
+
return withNext(grown, state.current, id);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Whether there is a value before the present. */
|
|
71
|
+
export const canUndo = <A>(state: History<A>): boolean => presentEntry(state).parent !== null;
|
|
72
|
+
|
|
73
|
+
/** Whether there is a value after the present on the branch last taken. */
|
|
74
|
+
export const canRedo = <A>(state: History<A>): boolean => presentEntry(state).next !== null;
|
|
75
|
+
|
|
76
|
+
/** Back one value; at the first, as it was. */
|
|
77
|
+
export const undo = <A>(state: History<A>): History<A> => {
|
|
78
|
+
const parent = presentEntry(state).parent;
|
|
79
|
+
return parent === null ? state : { ...state, current: parent };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Forward one value along the branch last taken; at its end, as it was. */
|
|
83
|
+
export const redo = <A>(state: History<A>): History<A> => {
|
|
84
|
+
const next = presentEntry(state).next;
|
|
85
|
+
return next === null ? state : { ...state, current: next };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The entries that followed the present: one per branch, in the order recorded. */
|
|
89
|
+
export const branches = <A>(state: History<A>): ReadonlyArray<HistoryEntry<A>> =>
|
|
90
|
+
state.entries.filter((entry) => entry.parent === state.current);
|
|
91
|
+
|
|
92
|
+
/** Redo from the present goes to `id`, when it is one of the present's branches. */
|
|
93
|
+
export const chooseBranch = <A>(state: History<A>, id: number): History<A> =>
|
|
94
|
+
branches(state).some((entry) => entry.id === id) ? withNext(state, state.current, id) : state;
|
|
95
|
+
|
|
96
|
+
/** The entries from the first to `id`, in order. */
|
|
97
|
+
export const pathTo = <A>(state: History<A>, id: number): ReadonlyArray<HistoryEntry<A>> => {
|
|
98
|
+
const path: Array<HistoryEntry<A>> = [];
|
|
99
|
+
for (let at: number | null = id; at !== null; at = entryOf(state, at).parent) {
|
|
100
|
+
path.unshift(entryOf(state, at));
|
|
101
|
+
}
|
|
102
|
+
return path;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Straight to entry `id`, wherever it is in the tree, with each step to it the branch
|
|
107
|
+
* redo takes, so undoing and redoing comes back to it.
|
|
108
|
+
*/
|
|
109
|
+
export const jumpTo = <A>(state: History<A>, id: number): History<A> => {
|
|
110
|
+
const path = pathTo(state, id);
|
|
111
|
+
const chosen = path
|
|
112
|
+
.slice(1)
|
|
113
|
+
.reduce((at, entry) => withNext(at, entry.parent ?? 0, entry.id), state);
|
|
114
|
+
return { ...chosen, current: id };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** How many steps the present is from the first value. */
|
|
118
|
+
export const depth = <A>(state: History<A>): number => pathTo(state, state.current).length - 1;
|