@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.
- package/CHANGELOG.md +21 -1
- package/README.md +14 -1
- package/dist/{index-01kwfekm.js → index-h3vf50fn.js} +2418 -85
- 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 +543 -149
- package/dist/index.js.map +8 -6
- package/dist/kernel/behaviours.d.ts.map +1 -1
- package/dist/kernel/boxSelect.d.ts +22 -0
- package/dist/kernel/boxSelect.d.ts.map +1 -0
- package/dist/kernel/build.d.ts +197 -6
- package/dist/kernel/build.d.ts.map +1 -1
- package/dist/kernel/cameraMotion.d.ts +3 -1
- package/dist/kernel/cameraMotion.d.ts.map +1 -1
- package/dist/kernel/cameraShake.d.ts +33 -0
- package/dist/kernel/cameraShake.d.ts.map +1 -0
- package/dist/kernel/describeScene.d.ts +50 -0
- package/dist/kernel/describeScene.d.ts.map +1 -0
- package/dist/kernel/description.d.ts +240 -2
- package/dist/kernel/description.d.ts.map +1 -1
- package/dist/kernel/diff.d.ts +18 -1
- package/dist/kernel/diff.d.ts.map +1 -1
- package/dist/kernel/equality.d.ts +13 -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 +116 -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/resources.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 -1
- 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 +614 -5
- 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/behaviours.ts +5 -0
- package/src/kernel/bodySources.ts +1 -1
- package/src/kernel/boxSelect.ts +73 -0
- package/src/kernel/build.ts +430 -0
- package/src/kernel/cameraMotion.ts +3 -0
- package/src/kernel/cameraShake.ts +87 -0
- package/src/kernel/describeScene.ts +224 -0
- package/src/kernel/description.ts +266 -1
- package/src/kernel/diff.ts +46 -1
- package/src/kernel/equality.ts +148 -0
- package/src/kernel/gizmo.ts +478 -0
- package/src/kernel/history.ts +118 -0
- package/src/kernel/index.ts +102 -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 +63 -12
- package/src/kernel/renderKit.ts +23 -1
- package/src/kernel/renderer.ts +6 -0
- package/src/kernel/resources.ts +69 -0
- package/src/kernel/sceneChanges.ts +265 -0
- package/src/kernel/sceneGraph.ts +461 -9
- 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 +305 -4
- 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-01kwfekm.js.map +0 -40
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small seeded random generator (mulberry32): the same numbers for the same seed on
|
|
3
|
+
* every page load, so randomly scattered scene parts are reproducible.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A generator of numbers in [0, 1) from `seed`, as `Math.random` would give them. */
|
|
9
|
+
export const seededRandom = (seed: number): (() => number) => {
|
|
10
|
+
let state = seed >>> 0;
|
|
11
|
+
return () => {
|
|
12
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
13
|
+
let t = state;
|
|
14
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
15
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setting up a shot by name (roadmap, "Beyond parity" 3): a vocabulary an assistant (or
|
|
3
|
+
* a person) can use instead of numbers. `shotCamera` frames a box from a named angle, as
|
|
4
|
+
* a photographer would ask for it, through `fitCamera`; `moodStaging` turns a lighting
|
|
5
|
+
* mood into lights, image-based lighting and a sky. Both return descriptions to keep in
|
|
6
|
+
* the Model, so a shot is undoable and replayable like any other change.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { Vector3 } from 'three';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
directionalLight,
|
|
14
|
+
environment,
|
|
15
|
+
hemisphereLight,
|
|
16
|
+
sky,
|
|
17
|
+
sunPositionFromAngles,
|
|
18
|
+
} from './build.ts';
|
|
19
|
+
import type { Camera, Environment, SceneNode, Sky, Vec3 } from './description.ts';
|
|
20
|
+
import { type Box, fitCamera } from './orbit.ts';
|
|
21
|
+
|
|
22
|
+
/** Where the camera looks from, round the subject. */
|
|
23
|
+
export type ShotAngle = 'front' | 'back' | 'left' | 'right' | 'top' | 'three-quarter' | 'low';
|
|
24
|
+
|
|
25
|
+
export const shotAngles: ReadonlyArray<ShotAngle> = [
|
|
26
|
+
'front',
|
|
27
|
+
'back',
|
|
28
|
+
'left',
|
|
29
|
+
'right',
|
|
30
|
+
'top',
|
|
31
|
+
'three-quarter',
|
|
32
|
+
'low',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* From the subject towards the camera, for each angle: in front is +z, as three's
|
|
37
|
+
* cameras look down −z; the top view stays a hair off the pole, as `OrbitControls` does.
|
|
38
|
+
*/
|
|
39
|
+
const directionOf: Record<ShotAngle, Vec3> = {
|
|
40
|
+
front: [0, 0.15, 1],
|
|
41
|
+
back: [0, 0.15, -1],
|
|
42
|
+
left: [-1, 0.15, 0],
|
|
43
|
+
right: [1, 0.15, 0],
|
|
44
|
+
top: [0, 1, 1e-4],
|
|
45
|
+
'three-quarter': [1, 0.7, 1],
|
|
46
|
+
low: [0.35, -0.02, 1],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The camera looking at `subject` from `angle`, as close as it can be with the box
|
|
51
|
+
* still inside the frame (`margin` 1.3 leaves room round it). `area` is the canvas's
|
|
52
|
+
* size, or its shape: only the ratio matters to a perspective camera.
|
|
53
|
+
*/
|
|
54
|
+
export const shotCamera = <C extends Camera>(
|
|
55
|
+
camera: C,
|
|
56
|
+
subject: Box,
|
|
57
|
+
angle: ShotAngle,
|
|
58
|
+
area: { readonly width: number; readonly height: number } = { width: 16, height: 10 },
|
|
59
|
+
margin = 1.3,
|
|
60
|
+
): C => {
|
|
61
|
+
const centre = new Vector3(...subject.min).add(new Vector3(...subject.max)).multiplyScalar(0.5);
|
|
62
|
+
const from = new Vector3(...directionOf[angle]).normalize().add(centre);
|
|
63
|
+
const aimed = {
|
|
64
|
+
...camera,
|
|
65
|
+
position: [from.x, from.y, from.z],
|
|
66
|
+
lookAt: [centre.x, centre.y, centre.z],
|
|
67
|
+
} as C;
|
|
68
|
+
// SAFETY: `fitCamera` returns the camera it was given with only its position, aim and
|
|
69
|
+
// zoom changed, so the kind of camera is the one passed in.
|
|
70
|
+
return fitCamera(aimed, subject, area, margin) as C;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A lighting mood, from the even light of a studio to a clear night. */
|
|
74
|
+
export type Mood = 'studio' | 'daylight' | 'sunset' | 'night';
|
|
75
|
+
|
|
76
|
+
export const moods: ReadonlyArray<Mood> = ['studio', 'daylight', 'sunset', 'night'];
|
|
77
|
+
|
|
78
|
+
/** What a mood stages: lights (keyed under the prefix), image-based light and a sky. */
|
|
79
|
+
export type Staging = {
|
|
80
|
+
readonly lights: ReadonlyArray<SceneNode>;
|
|
81
|
+
readonly environment: Environment | null;
|
|
82
|
+
readonly sky: Sky | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The staging for `mood`, its lights keyed `${prefix}:…`. */
|
|
86
|
+
export const moodStaging = (mood: Mood, prefix = 'mood'): Staging => {
|
|
87
|
+
switch (mood) {
|
|
88
|
+
case 'studio':
|
|
89
|
+
return {
|
|
90
|
+
lights: [
|
|
91
|
+
hemisphereLight({
|
|
92
|
+
key: `${prefix}:fill`,
|
|
93
|
+
color: 0xffffff,
|
|
94
|
+
groundColor: 0x6f6a62,
|
|
95
|
+
intensity: 0.8,
|
|
96
|
+
}),
|
|
97
|
+
directionalLight({
|
|
98
|
+
key: `${prefix}:key`,
|
|
99
|
+
position: [4, 7, 3],
|
|
100
|
+
intensity: 2,
|
|
101
|
+
castShadow: true,
|
|
102
|
+
}),
|
|
103
|
+
],
|
|
104
|
+
environment: environment({ environmentIntensity: 0.35 }),
|
|
105
|
+
sky: null,
|
|
106
|
+
};
|
|
107
|
+
case 'daylight':
|
|
108
|
+
return {
|
|
109
|
+
lights: [
|
|
110
|
+
hemisphereLight({
|
|
111
|
+
key: `${prefix}:sky`,
|
|
112
|
+
color: 0xcfe6ff,
|
|
113
|
+
groundColor: 0x8a7a60,
|
|
114
|
+
intensity: 1,
|
|
115
|
+
}),
|
|
116
|
+
directionalLight({
|
|
117
|
+
key: `${prefix}:sun`,
|
|
118
|
+
position: [6, 9, 4],
|
|
119
|
+
intensity: 3,
|
|
120
|
+
castShadow: true,
|
|
121
|
+
}),
|
|
122
|
+
],
|
|
123
|
+
environment: environment({ environmentIntensity: 0.25 }),
|
|
124
|
+
sky: sky({ sunPosition: sunPositionFromAngles(0.85, 0.35) }),
|
|
125
|
+
};
|
|
126
|
+
case 'sunset':
|
|
127
|
+
return {
|
|
128
|
+
lights: [
|
|
129
|
+
hemisphereLight({
|
|
130
|
+
key: `${prefix}:sky`,
|
|
131
|
+
color: 0xffb27a,
|
|
132
|
+
groundColor: 0x3a2a40,
|
|
133
|
+
intensity: 0.6,
|
|
134
|
+
}),
|
|
135
|
+
directionalLight({
|
|
136
|
+
key: `${prefix}:sun`,
|
|
137
|
+
color: 0xff9a5a,
|
|
138
|
+
position: [-8, 2.5, 3],
|
|
139
|
+
intensity: 2.5,
|
|
140
|
+
castShadow: true,
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
environment: null,
|
|
144
|
+
sky: sky({ sunPosition: sunPositionFromAngles(0.52, 0.25), turbidity: 8, rayleigh: 3 }),
|
|
145
|
+
};
|
|
146
|
+
case 'night':
|
|
147
|
+
return {
|
|
148
|
+
lights: [
|
|
149
|
+
hemisphereLight({
|
|
150
|
+
key: `${prefix}:sky`,
|
|
151
|
+
color: 0x3a4a80,
|
|
152
|
+
groundColor: 0x101018,
|
|
153
|
+
intensity: 0.35,
|
|
154
|
+
}),
|
|
155
|
+
directionalLight({
|
|
156
|
+
key: `${prefix}:moon`,
|
|
157
|
+
color: 0xaabfff,
|
|
158
|
+
position: [-3, 8, -4],
|
|
159
|
+
intensity: 0.8,
|
|
160
|
+
castShadow: true,
|
|
161
|
+
}),
|
|
162
|
+
],
|
|
163
|
+
environment: null,
|
|
164
|
+
sky: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A measuring tape as data (roadmap, "Beyond parity" 5): two ends placed on the scene
|
|
3
|
+
* by clicks, the distance between them, and a dashed preview from the first end to the
|
|
4
|
+
* pointer while the second is being placed. Neither three.js nor Drei has one; it is
|
|
5
|
+
* built the way the gizmos are: `placeTapeEnd` and `pointTape` return the next tape for
|
|
6
|
+
* the Model to keep, `tapeNodes` describes it, and `tapeLength` measures it.
|
|
7
|
+
*
|
|
8
|
+
* A first click places one end, a second the other; a third starts a new tape there.
|
|
9
|
+
* With `snap`, each end is rounded to that step on every axis.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import { basic, group, line, mesh, sphere } from './build.ts';
|
|
14
|
+
import type { Rgb, SceneNode, Vec3 } from './description.ts';
|
|
15
|
+
|
|
16
|
+
export type Tape = {
|
|
17
|
+
/** The first end, once placed. */
|
|
18
|
+
readonly from: Vec3 | null;
|
|
19
|
+
/** The second end, once placed: the tape is then measured. */
|
|
20
|
+
readonly to: Vec3 | null;
|
|
21
|
+
/** Where the pointer is on the scene while the second end is being placed. */
|
|
22
|
+
readonly pointer: Vec3 | null;
|
|
23
|
+
/** Rounds each end to this step on every axis, or not when `null`. */
|
|
24
|
+
readonly snap: number | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** An empty tape, snapping to `snap` when given. */
|
|
28
|
+
export const tape = (options: { readonly snap?: number | null } = {}): Tape => ({
|
|
29
|
+
from: null,
|
|
30
|
+
to: null,
|
|
31
|
+
pointer: null,
|
|
32
|
+
snap: options.snap ?? null,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const snapped = (state: Tape, point: Vec3): Vec3 => {
|
|
36
|
+
const step = state.snap;
|
|
37
|
+
if (step === null) return point;
|
|
38
|
+
return [
|
|
39
|
+
Math.round(point[0] / step) * step,
|
|
40
|
+
Math.round(point[1] / step) * step,
|
|
41
|
+
Math.round(point[2] / step) * step,
|
|
42
|
+
];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A click at `point`: the first end, the second, or (when both are placed) a new tape. */
|
|
46
|
+
export const placeTapeEnd = (state: Tape, point: Vec3): Tape => {
|
|
47
|
+
const at = snapped(state, point);
|
|
48
|
+
if (state.from !== null && state.to === null) return { ...state, to: at, pointer: null };
|
|
49
|
+
return { ...state, from: at, to: null, pointer: null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The pointer over the scene at `point` (or off it, `null`), for the preview. */
|
|
53
|
+
export const pointTape = (state: Tape, point: Vec3 | null): Tape =>
|
|
54
|
+
state.from !== null && state.to === null
|
|
55
|
+
? { ...state, pointer: point === null ? null : snapped(state, point) }
|
|
56
|
+
: state;
|
|
57
|
+
|
|
58
|
+
/** An empty tape, keeping its snap. */
|
|
59
|
+
export const clearTape = (state: Tape): Tape => ({ ...tape({ snap: state.snap }) });
|
|
60
|
+
|
|
61
|
+
/** Where the tape's far end is: the second end, or the pointer while it is placed. */
|
|
62
|
+
const farEnd = (state: Tape): Vec3 | null => state.to ?? state.pointer;
|
|
63
|
+
|
|
64
|
+
const distance = (a: Vec3, b: Vec3): number => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
65
|
+
|
|
66
|
+
/** The tape's length: between its ends, or to the pointer while the second is placed. */
|
|
67
|
+
export const tapeLength = (state: Tape): number | undefined => {
|
|
68
|
+
const end = farEnd(state);
|
|
69
|
+
return state.from === null || end === null ? undefined : distance(state.from, end);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The middle of the tape, for a label there. */
|
|
73
|
+
export const tapeMiddle = (state: Tape): Vec3 | undefined => {
|
|
74
|
+
const end = farEnd(state);
|
|
75
|
+
if (state.from === null || end === null) return undefined;
|
|
76
|
+
return [(state.from[0] + end[0]) / 2, (state.from[1] + end[1]) / 2, (state.from[2] + end[2]) / 2];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type TapeLook = {
|
|
80
|
+
readonly color: Rgb;
|
|
81
|
+
/** The line's width in pixels. */
|
|
82
|
+
readonly width: number;
|
|
83
|
+
/** The end balls' radius in scene units. */
|
|
84
|
+
readonly endRadius: number;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const endBall = (key: string, at: Vec3, look: TapeLook): SceneNode =>
|
|
88
|
+
mesh({
|
|
89
|
+
key,
|
|
90
|
+
position: at,
|
|
91
|
+
geometry: sphere({ radius: look.endRadius, widthSegments: 16, heightSegments: 8 }),
|
|
92
|
+
material: basic({ color: look.color }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The tape under `key`: a ball at each placed end and a line between them, dashed while
|
|
97
|
+
* it runs to the pointer. Nothing in it is picked, so clicks reach the scene beneath.
|
|
98
|
+
*/
|
|
99
|
+
export const tapeNodes = (key: string, state: Tape, look: Partial<TapeLook> = {}): SceneNode => {
|
|
100
|
+
const shown: TapeLook = {
|
|
101
|
+
color: look.color ?? 0xffcc33,
|
|
102
|
+
width: look.width ?? 3,
|
|
103
|
+
endRadius: look.endRadius ?? 0.03,
|
|
104
|
+
};
|
|
105
|
+
const end = farEnd(state);
|
|
106
|
+
const ends = [state.from, state.to].flatMap((at, index) =>
|
|
107
|
+
at === null ? [] : [endBall(`${key}:end:${index}`, at, shown)],
|
|
108
|
+
);
|
|
109
|
+
const span =
|
|
110
|
+
state.from === null || end === null
|
|
111
|
+
? []
|
|
112
|
+
: [
|
|
113
|
+
line({
|
|
114
|
+
key: `${key}:line`,
|
|
115
|
+
points: [state.from, end],
|
|
116
|
+
color: shown.color,
|
|
117
|
+
width: shown.width,
|
|
118
|
+
dashed: state.to === null,
|
|
119
|
+
dashSize: 0.08,
|
|
120
|
+
gapSize: 0.05,
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
return group({ key, children: [...span, ...ends] });
|
|
124
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trails, as Drei's `Trail` (`bf6f4ad`, `src/core/Trail.tsx`): a ribbon behind a moving
|
|
3
|
+
* node through the places it has been. Drei records the target once a frame into
|
|
4
|
+
* `length × 10` points, shifting in `decay` new ones each time it moves at least
|
|
5
|
+
* `stride`, every `interval` frames, and draws them with `meshline`. Here the same rules
|
|
6
|
+
* run on a clock of 60 steps a second of scene time rather than per frame, so a trail is
|
|
7
|
+
* as long at 30 frames a second as at 120; and the ribbon is a plain mesh turned to face
|
|
8
|
+
* the camera each frame, with vertex colours, so either renderer draws it. Its width is
|
|
9
|
+
* Drei's: a tenth of `width`, in scene units, `taper` and `fade` narrowing and fading it
|
|
10
|
+
* towards the tail (Drei takes an `attenuation` function).
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
BufferAttribute,
|
|
16
|
+
BufferGeometry,
|
|
17
|
+
Color,
|
|
18
|
+
DoubleSide,
|
|
19
|
+
Mesh,
|
|
20
|
+
MeshBasicMaterial,
|
|
21
|
+
Vector3,
|
|
22
|
+
} from 'three';
|
|
23
|
+
|
|
24
|
+
import type { Trail } from './description.ts';
|
|
25
|
+
|
|
26
|
+
/** Drei records a point a frame; this records one a step of this many a second. */
|
|
27
|
+
const stepsPerSecond = 60;
|
|
28
|
+
|
|
29
|
+
export type TrailRibbon = {
|
|
30
|
+
/** The ribbon, in the space the positions are in; never picked. */
|
|
31
|
+
readonly object: Mesh;
|
|
32
|
+
/** Records the node at `position` for every step up to scene time `now`. */
|
|
33
|
+
readonly record: (position: Vector3, now: number) => void;
|
|
34
|
+
/** Turns the ribbon to face a camera at `eye`. */
|
|
35
|
+
readonly face: (eye: Vector3) => void;
|
|
36
|
+
/** Whether every recorded point is where the node is: the trail has caught up. */
|
|
37
|
+
readonly settled: () => boolean;
|
|
38
|
+
/** Applies a new look; a new length starts the trail again at `position`. */
|
|
39
|
+
readonly restyle: (trail: Trail, position: Vector3, now: number) => void;
|
|
40
|
+
readonly dispose: () => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** How wide the ribbon is `along` the way from tail (0) to head (1). */
|
|
44
|
+
const widthAt = (trail: Trail, along: number): number => {
|
|
45
|
+
const full = trail.width * 0.1;
|
|
46
|
+
if (trail.taper === 'linear') return full * along;
|
|
47
|
+
if (trail.taper === 'quadratic') return full * along * along;
|
|
48
|
+
return full;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A ribbon of `trail.length × 10` points, all at `start`. Allocation-free once made. */
|
|
52
|
+
export const createTrailRibbon = (first: Trail, start: Vector3, now: number): TrailRibbon => {
|
|
53
|
+
let trail = first;
|
|
54
|
+
let count = 0;
|
|
55
|
+
let points = new Float32Array(0);
|
|
56
|
+
const geometry = new BufferGeometry();
|
|
57
|
+
const material = new MeshBasicMaterial({
|
|
58
|
+
vertexColors: true,
|
|
59
|
+
transparent: true,
|
|
60
|
+
side: DoubleSide,
|
|
61
|
+
depthWrite: false,
|
|
62
|
+
});
|
|
63
|
+
const object = new Mesh(geometry, material);
|
|
64
|
+
object.frustumCulled = false;
|
|
65
|
+
object.raycast = () => undefined;
|
|
66
|
+
// Not a node: marked so tools walking the scene can tell it apart.
|
|
67
|
+
object.name = 'effect-three:trail';
|
|
68
|
+
const last = new Vector3();
|
|
69
|
+
const here = new Vector3();
|
|
70
|
+
const ahead = new Vector3();
|
|
71
|
+
const behind = new Vector3();
|
|
72
|
+
const side = new Vector3();
|
|
73
|
+
const toEye = new Vector3();
|
|
74
|
+
const tint = new Color();
|
|
75
|
+
let clock = now;
|
|
76
|
+
let phase = 0;
|
|
77
|
+
|
|
78
|
+
/** Sizes the buffers for the trail's length and fills every point with `at`. */
|
|
79
|
+
const reset = (at: Vector3, time: number): void => {
|
|
80
|
+
count = Math.max(2, Math.round(trail.length * 10));
|
|
81
|
+
points = new Float32Array(count * 3);
|
|
82
|
+
for (let point = 0; point < count; point++) at.toArray(points, point * 3);
|
|
83
|
+
const index: Array<number> = [];
|
|
84
|
+
for (let point = 0; point < count - 1; point++) {
|
|
85
|
+
const a = point * 2;
|
|
86
|
+
index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
|
|
87
|
+
}
|
|
88
|
+
geometry.setIndex(index);
|
|
89
|
+
geometry.setAttribute('position', new BufferAttribute(new Float32Array(count * 6), 3));
|
|
90
|
+
geometry.setAttribute('color', new BufferAttribute(new Float32Array(count * 8), 4));
|
|
91
|
+
paint();
|
|
92
|
+
last.copy(at);
|
|
93
|
+
clock = time;
|
|
94
|
+
phase = 0;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Colours each pair of vertices: the trail's colour, fading to the tail with `fade`. */
|
|
98
|
+
const paint = (): void => {
|
|
99
|
+
const colors = geometry.getAttribute('color');
|
|
100
|
+
tint.setHex(trail.color);
|
|
101
|
+
for (let point = 0; point < count; point++) {
|
|
102
|
+
const alpha = trail.fade ? point / (count - 1) : 1;
|
|
103
|
+
colors.setXYZW(point * 2, tint.r, tint.g, tint.b, alpha);
|
|
104
|
+
colors.setXYZW(point * 2 + 1, tint.r, tint.g, tint.b, alpha);
|
|
105
|
+
}
|
|
106
|
+
colors.needsUpdate = true;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Drei's `shiftLeft` and push: the oldest point drops, `at` joins as the newest. */
|
|
110
|
+
const push = (at: Vector3): void => {
|
|
111
|
+
points.copyWithin(0, 3);
|
|
112
|
+
at.toArray(points, (count - 1) * 3);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
reset(start, now);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
object,
|
|
119
|
+
record: (position, time) => {
|
|
120
|
+
// At most a trail's worth of steps: an idle scene catches up at once.
|
|
121
|
+
const due = Math.min(count, Math.floor((time - clock) * stepsPerSecond));
|
|
122
|
+
if (due <= 0) return;
|
|
123
|
+
clock += due / stepsPerSecond;
|
|
124
|
+
for (let step = 0; step < due; step++) {
|
|
125
|
+
if (phase === 0) {
|
|
126
|
+
for (let shift = 0; shift < trail.decay; shift++) {
|
|
127
|
+
if (position.distanceTo(last) < trail.stride) continue;
|
|
128
|
+
push(position);
|
|
129
|
+
}
|
|
130
|
+
last.copy(position);
|
|
131
|
+
}
|
|
132
|
+
phase = (phase + 1) % Math.max(1, Math.round(trail.interval));
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
face: (eye) => {
|
|
136
|
+
const position = geometry.getAttribute('position');
|
|
137
|
+
for (let point = 0; point < count; point++) {
|
|
138
|
+
here.fromArray(points, point * 3);
|
|
139
|
+
ahead.fromArray(points, Math.min(point + 1, count - 1) * 3);
|
|
140
|
+
behind.fromArray(points, Math.max(point - 1, 0) * 3);
|
|
141
|
+
// Across the ribbon: square to its direction here and to the view of it.
|
|
142
|
+
side.subVectors(ahead, behind).cross(toEye.subVectors(eye, here));
|
|
143
|
+
const length = side.length();
|
|
144
|
+
if (length > 1e-9) side.multiplyScalar(widthAt(trail, point / (count - 1)) / 2 / length);
|
|
145
|
+
else side.set(0, 0, 0);
|
|
146
|
+
position.setXYZ(point * 2, here.x + side.x, here.y + side.y, here.z + side.z);
|
|
147
|
+
position.setXYZ(point * 2 + 1, here.x - side.x, here.y - side.y, here.z - side.z);
|
|
148
|
+
}
|
|
149
|
+
position.needsUpdate = true;
|
|
150
|
+
},
|
|
151
|
+
settled: () => {
|
|
152
|
+
const end = (count - 1) * 3;
|
|
153
|
+
for (let point = 0; point < end; point += 3) {
|
|
154
|
+
if (points[point] !== points[end] || points[point + 1] !== points[end + 1]) return false;
|
|
155
|
+
if (points[point + 2] !== points[end + 2]) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
},
|
|
159
|
+
restyle: (next, position, time) => {
|
|
160
|
+
const resized = Math.round(next.length * 10) !== Math.round(trail.length * 10);
|
|
161
|
+
trail = next;
|
|
162
|
+
if (resized) reset(position, time);
|
|
163
|
+
else paint();
|
|
164
|
+
},
|
|
165
|
+
dispose: () => {
|
|
166
|
+
geometry.dispose();
|
|
167
|
+
material.dispose();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A viewport gizmo as data, after Drei's `GizmoHelper` with `GizmoViewport` (`bf6f4ad`,
|
|
3
|
+
* `src/core/GizmoHelper.tsx`, `src/core/GizmoViewport.tsx`): three coloured axes, turned
|
|
4
|
+
* as the camera sees the world, with a head at each end. Drei draws it in a `Hud` and,
|
|
5
|
+
* when a head is pressed, tweens the camera to look from that axis; here
|
|
6
|
+
* `viewportGizmoNodes` describes it for a scene's `hud`, `viewportAxisOf` names the head
|
|
7
|
+
* a hit is on, and `cameraAlong` returns the camera looking from that axis, for the
|
|
8
|
+
* Model to keep (a camera's `damping` eases it round, as Drei's tween does).
|
|
9
|
+
*
|
|
10
|
+
* The positive heads carry their letters, drawn with bars, as Drei's canvas textures
|
|
11
|
+
* carry theirs; the negative heads are smaller and fainter, as Drei's.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { Euler, Matrix4, Quaternion, Vector3 } from 'three';
|
|
16
|
+
|
|
17
|
+
import { basic, billboard, box, circle, fixed, group, mesh } from './build.ts';
|
|
18
|
+
import type { Camera, SceneNode, Vec3 } from './description.ts';
|
|
19
|
+
|
|
20
|
+
/** An axis end: a positive or negative world axis. */
|
|
21
|
+
export type ViewAxis = 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
|
|
22
|
+
|
|
23
|
+
export type ViewportGizmoOptions = {
|
|
24
|
+
/** Pixels from the middle to a head: Drei's `scale` of 40. */
|
|
25
|
+
readonly size: number;
|
|
26
|
+
/** The head under the pointer, drawn larger, as Drei's. */
|
|
27
|
+
readonly hovered: ViewAxis | null;
|
|
28
|
+
/** Leaves out the negative heads (Drei's `hideNegativeAxes`). */
|
|
29
|
+
readonly hideNegativeAxes: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Drei's `axisColors`. */
|
|
33
|
+
const colours = { x: 0xff2060, y: 0x20df80, z: 0x2080ff } as const;
|
|
34
|
+
/** Drei's `labelColor`. */
|
|
35
|
+
const ink = 0x000000;
|
|
36
|
+
|
|
37
|
+
const directions: Record<ViewAxis, Vec3> = {
|
|
38
|
+
x: [1, 0, 0],
|
|
39
|
+
y: [0, 1, 0],
|
|
40
|
+
z: [0, 0, 1],
|
|
41
|
+
'-x': [-1, 0, 0],
|
|
42
|
+
'-y': [0, -1, 0],
|
|
43
|
+
'-z': [0, 0, -1],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const axes = ['x', 'y', 'z', '-x', '-y', '-z'] as const;
|
|
47
|
+
|
|
48
|
+
/** How the camera is turned: three's `Object3D.lookAt` for a camera, up the world's y. */
|
|
49
|
+
const cameraTurn = (camera: Camera): Quaternion =>
|
|
50
|
+
new Quaternion().setFromRotationMatrix(
|
|
51
|
+
new Matrix4().lookAt(
|
|
52
|
+
new Vector3(...camera.position),
|
|
53
|
+
new Vector3(...camera.lookAt),
|
|
54
|
+
new Vector3(0, 1, 0),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** The gizmo's turn: the camera's undone, so the axes show as the camera sees the world. */
|
|
59
|
+
const gizmoTurn = (camera: Camera): Vec3 => {
|
|
60
|
+
const turn = new Euler().setFromQuaternion(cameraTurn(camera).invert());
|
|
61
|
+
return [turn.x, turn.y, turn.z];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A bar of a letter: length and angle (radians from across) in head units, at `at`. */
|
|
65
|
+
const stroke = (key: string, length: number, angle: number, at: readonly [number, number]) =>
|
|
66
|
+
mesh({
|
|
67
|
+
key,
|
|
68
|
+
position: [at[0], at[1], 0.01],
|
|
69
|
+
rotation: fixed([0, 0, angle]),
|
|
70
|
+
geometry: box({ width: length, height: 0.07, depth: 0.001 }),
|
|
71
|
+
material: basic({ color: ink }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The letters, in bars, about the middle of a head 0.5 across. */
|
|
75
|
+
const letters: Record<
|
|
76
|
+
'x' | 'y' | 'z',
|
|
77
|
+
ReadonlyArray<readonly [number, number, readonly [number, number]]>
|
|
78
|
+
> = {
|
|
79
|
+
x: [
|
|
80
|
+
[0.34, Math.PI / 4, [0, 0]],
|
|
81
|
+
[0.34, -Math.PI / 4, [0, 0]],
|
|
82
|
+
],
|
|
83
|
+
y: [
|
|
84
|
+
[0.14, Math.PI / 2, [0, -0.07]],
|
|
85
|
+
[0.17, Math.PI / 3, [0.04, 0.06]],
|
|
86
|
+
[0.17, -Math.PI / 3, [-0.04, 0.06]],
|
|
87
|
+
],
|
|
88
|
+
z: [
|
|
89
|
+
[0.22, 0, [0, 0.11]],
|
|
90
|
+
[0.22, 0, [0, -0.11]],
|
|
91
|
+
[0.3, Math.PI / 4, [0, 0]],
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isPositive = (axis: ViewAxis): axis is 'x' | 'y' | 'z' => axis.length === 1;
|
|
96
|
+
|
|
97
|
+
const colourOf = (axis: ViewAxis): number =>
|
|
98
|
+
colours[isPositive(axis) ? axis : (axis.slice(1) as 'x' | 'y' | 'z')];
|
|
99
|
+
|
|
100
|
+
/** Drei's head sizes: a labelled head whole, a bare one three quarters, 1.2 under the pointer. */
|
|
101
|
+
const headScale = (axis: ViewAxis, hovered: ViewAxis | null): number =>
|
|
102
|
+
(isPositive(axis) ? 1 : 0.75) * (axis === hovered ? 1.2 : 1);
|
|
103
|
+
|
|
104
|
+
const labelOf = (key: string, axis: ViewAxis): ReadonlyArray<SceneNode> =>
|
|
105
|
+
isPositive(axis)
|
|
106
|
+
? letters[axis].map(([length, angle, at], index) =>
|
|
107
|
+
stroke(`${key}:${axis}:letter:${index}`, length, angle, at),
|
|
108
|
+
)
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
/** A head: a disc facing the eye, in its axis's colour, with its letter on it. */
|
|
112
|
+
const head = (key: string, axis: ViewAxis, hovered: ViewAxis | null): SceneNode => {
|
|
113
|
+
const scale = headScale(axis, hovered);
|
|
114
|
+
return group({
|
|
115
|
+
key: `${key}:${axis}`,
|
|
116
|
+
position: directions[axis],
|
|
117
|
+
rotation: billboard(),
|
|
118
|
+
scale: [scale, scale, scale],
|
|
119
|
+
interactive: true,
|
|
120
|
+
children: [
|
|
121
|
+
mesh({
|
|
122
|
+
key: `${key}:${axis}:disc`,
|
|
123
|
+
geometry: circle({ radius: 0.25, segments: 32 }),
|
|
124
|
+
material: basic({
|
|
125
|
+
color: colourOf(axis),
|
|
126
|
+
opacity: isPositive(axis) ? 1 : 0.75,
|
|
127
|
+
transparent: !isPositive(axis),
|
|
128
|
+
}),
|
|
129
|
+
}),
|
|
130
|
+
...labelOf(key, axis),
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** An axis's bar from the middle, as Drei's `Axis` (0.8 × 0.05 × 0.05). */
|
|
136
|
+
const bar = (key: string, axis: 'x' | 'y' | 'z'): SceneNode => {
|
|
137
|
+
const [x, y, z] = directions[axis];
|
|
138
|
+
return mesh({
|
|
139
|
+
key: `${key}:${axis}:bar`,
|
|
140
|
+
position: [x * 0.4, y * 0.4, z * 0.4],
|
|
141
|
+
geometry: box({
|
|
142
|
+
width: axis === 'x' ? 0.8 : 0.05,
|
|
143
|
+
height: axis === 'y' ? 0.8 : 0.05,
|
|
144
|
+
depth: axis === 'z' ? 0.8 : 0.05,
|
|
145
|
+
}),
|
|
146
|
+
material: basic({ color: colours[axis] }),
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The gizmo for `camera`, keyed under `key` (`${key}:x`, `${key}:-y` and so on), for a
|
|
152
|
+
* scene's `hud`: Drei's defaults, 40 pixels to a head, every head shown.
|
|
153
|
+
*/
|
|
154
|
+
export const viewportGizmoNodes = (
|
|
155
|
+
key: string,
|
|
156
|
+
camera: Camera,
|
|
157
|
+
options: Partial<ViewportGizmoOptions> = {},
|
|
158
|
+
): SceneNode => {
|
|
159
|
+
const size = options.size ?? 40;
|
|
160
|
+
const hovered = options.hovered ?? null;
|
|
161
|
+
const shown = options.hideNegativeAxes === true ? axes.filter(isPositive) : axes;
|
|
162
|
+
return group({
|
|
163
|
+
key,
|
|
164
|
+
rotation: fixed(gizmoTurn(camera)),
|
|
165
|
+
scale: [size, size, size],
|
|
166
|
+
children: [
|
|
167
|
+
bar(key, 'x'),
|
|
168
|
+
bar(key, 'y'),
|
|
169
|
+
bar(key, 'z'),
|
|
170
|
+
...shown.map((axis) => head(key, axis, hovered)),
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** The head a hit `key` (or any part of one) is on, if it is one of this gizmo's. */
|
|
176
|
+
export const viewportAxisOf = (key: string, hit: string): ViewAxis | undefined => {
|
|
177
|
+
const [owner, axis] = hit.split(':');
|
|
178
|
+
if (owner !== key) return undefined;
|
|
179
|
+
return axes.find((candidate) => candidate === axis);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `camera` looking from `axis` at what it looks at, as far away as it was: Drei's
|
|
184
|
+
* `tweenCamera` to that head, where it ends. Looking straight up or down, it stays a
|
|
185
|
+
* hair off the pole toward +z, as `OrbitControls` keeps its camera off them.
|
|
186
|
+
*/
|
|
187
|
+
export const cameraAlong = <C extends Camera>(camera: C, axis: ViewAxis): C => {
|
|
188
|
+
const target = new Vector3(...camera.lookAt);
|
|
189
|
+
const distance = new Vector3(...camera.position).distanceTo(target);
|
|
190
|
+
const direction = new Vector3(...directions[axis]);
|
|
191
|
+
if (axis === 'y' || axis === '-y') direction.z = 1e-6;
|
|
192
|
+
const position = direction.normalize().multiplyScalar(distance).add(target);
|
|
193
|
+
return { ...camera, position: [position.x, position.y, position.z] };
|
|
194
|
+
};
|