@volter/blender-engine 0.1.8 → 0.1.10
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/browser/blender-emscripten-engine.mts +4 -4
- package/browser/blender-engine.mts +5 -5
- package/browser/blender-wali-engine.mts +7 -7
- package/browser/rna.ts +2 -2
- package/browser/runtime.ts +2 -2
- package/browser/session.py +209 -34
- package/browser/three/attach-presenter.ts +1 -1
- package/browser/three/blender-node-graph.ts +15 -6
- package/browser/three/blender-runtime-armature.ts +1 -1
- package/browser/three/blender-runtime-camera-view.ts +137 -0
- package/browser/three/blender-runtime-cursor.ts +243 -0
- package/browser/three/blender-runtime-extras.ts +772 -0
- package/browser/three/blender-runtime-lighting.ts +20 -5
- package/browser/three/blender-runtime-view.ts +454 -7
- package/browser/three/blender-workbench-material.ts +115 -0
- package/browser/three/release.ts +1 -1
- package/browser/{vgai_three.py → volter_three.py} +19 -19
- package/browser/worker.ts +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLENDER'S OVERLAY EXTRAS — how the 3D View draws the objects that have no surface: a camera,
|
|
3
|
+
* a light, an empty. Transcribed from the overlay engine (`draw/engines/overlay/overlay_camera.hh`,
|
|
4
|
+
* `overlay_light.hh`, `overlay_empty.hh`), its shapes (`overlay_shape.cc`) and the extra vertex
|
|
5
|
+
* shaders (`overlay_extra_vert.glsl`, `overlay_extra_groundline_vert.glsl`).
|
|
6
|
+
*
|
|
7
|
+
* THE COLOUR is `object_wire_color`: the selection's orange for a selected object (the lighter
|
|
8
|
+
* active one for the active object), else the type's theme colour — black for a camera, a light
|
|
9
|
+
* and an empty in Blender 5.2's default theme, read back from the installed Blender. Lines are
|
|
10
|
+
* one UI pixel wide.
|
|
11
|
+
*
|
|
12
|
+
* A CAMERA is its frame and the wires from the eye to its corners (`BKE_camera_view_frame_ex`
|
|
13
|
+
* at the camera's display size, fitted to the render's shape), and a triangle over the frame,
|
|
14
|
+
* filled for the scene's camera. The camera a camera view looks through is not drawn: its
|
|
15
|
+
* frame is the view's border, and Blender draws nothing else of it there; the triangle also
|
|
16
|
+
* hides from the camera's own eye.
|
|
17
|
+
*
|
|
18
|
+
* A LIGHT is a screen-sized icon at its origin — a diamond and a dashed ring inside a dashed
|
|
19
|
+
* outer ring, sun rays for a sun — a line down to the floor with a mark where it lands, in the
|
|
20
|
+
* theme's light colour, and its shape in the world: a point light's radius as a circle facing
|
|
21
|
+
* the view, a sun's direction line, an area light's square or disk, a spot's radius. A spot's
|
|
22
|
+
* cone is not drawn.
|
|
23
|
+
*
|
|
24
|
+
* AN EMPTY is its display shape at its display size: plain axes, arrows (with their letters and
|
|
25
|
+
* the markers at their ends), a single arrow, a circle, a cube, a sphere, a cone or an image.
|
|
26
|
+
*/
|
|
27
|
+
import * as THREE from 'three';
|
|
28
|
+
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
|
29
|
+
import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
|
|
30
|
+
import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import type { CameraData } from './blender-runtime-camera-view';
|
|
33
|
+
import type { LightData } from './blender-runtime-lighting';
|
|
34
|
+
import { presenterChanged } from './blender-presenter-change';
|
|
35
|
+
|
|
36
|
+
const scalar = z.number().finite();
|
|
37
|
+
/** `session.py`'s `frame["empties"]`. */
|
|
38
|
+
export const emptySchema = z.object({
|
|
39
|
+
display: z.enum(['PLAIN_AXES', 'ARROWS', 'SINGLE_ARROW', 'CIRCLE', 'CUBE', 'SPHERE', 'CONE', 'IMAGE']),
|
|
40
|
+
size: scalar,
|
|
41
|
+
image: z
|
|
42
|
+
.object({
|
|
43
|
+
name: z.string().nullable(),
|
|
44
|
+
offset: z.tuple([scalar, scalar]),
|
|
45
|
+
depth: z.enum(['DEFAULT', 'FRONT', 'BACK']),
|
|
46
|
+
side: z.enum(['DOUBLE_SIDED', 'FRONT', 'BACK']),
|
|
47
|
+
perspective: z.boolean(),
|
|
48
|
+
orthographic: z.boolean(),
|
|
49
|
+
axis_aligned: z.boolean(),
|
|
50
|
+
opacity: scalar.nullable(),
|
|
51
|
+
tint: z.tuple([scalar, scalar, scalar]).default([1, 1, 1]),
|
|
52
|
+
premultiplied: z.boolean().default(false),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
});
|
|
56
|
+
export type EmptyData = z.infer<typeof emptySchema>;
|
|
57
|
+
|
|
58
|
+
/** Blender 5.2's default theme: `object_selected`, `object_active`, the types' wire colours and
|
|
59
|
+
* the light's (whose alpha the ground line keeps and the icon does not). */
|
|
60
|
+
const THEME = {
|
|
61
|
+
selected: 0xed5700,
|
|
62
|
+
active: 0xffa028,
|
|
63
|
+
camera: 0x000000,
|
|
64
|
+
light: 0x000000,
|
|
65
|
+
lightAlpha: 0x50 / 255,
|
|
66
|
+
empty: 0x000000,
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/** The icons' size unit: Blender's UI pixel (`theme.sizes.pixel`), one CSS pixel here. */
|
|
70
|
+
const LINE_WIDTH = 1;
|
|
71
|
+
|
|
72
|
+
type Segments = number[];
|
|
73
|
+
const seg = (out: Segments, a: readonly number[], b: readonly number[]) => out.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!);
|
|
74
|
+
|
|
75
|
+
/** `ring_vertices`: `n` points round a circle of radius `r`, from +X, in the XY plane. */
|
|
76
|
+
function ring(r: number, n: number): [number, number][] {
|
|
77
|
+
return Array.from({ length: n }, (_, i) => {
|
|
78
|
+
const angle = (2 * Math.PI * i) / n;
|
|
79
|
+
return [r * Math.cos(angle), r * Math.sin(angle)];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** `append_line_loop`: every edge of the loop, or every other one when `dashed`. */
|
|
83
|
+
function loop(out: Segments, points: [number, number][], z = 0, dashed = false): void {
|
|
84
|
+
const step = dashed ? 2 : 1;
|
|
85
|
+
for (let i = 0; i < Math.floor(points.length / step); i++) {
|
|
86
|
+
const a = points[(i * step) % points.length]!;
|
|
87
|
+
const b = points[(i * step + 1) % points.length]!;
|
|
88
|
+
seg(out, [a[0], a[1], z], [b[0], b[1], z]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One object's shapes in its own frame, the world, or on the screen. */
|
|
93
|
+
interface ObjectInput {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly type: string;
|
|
96
|
+
readonly matrix: readonly (readonly number[])[];
|
|
97
|
+
readonly selected: boolean;
|
|
98
|
+
readonly light?: string | null | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ExtrasInput {
|
|
102
|
+
readonly objects: readonly ObjectInput[];
|
|
103
|
+
readonly active: string | null;
|
|
104
|
+
readonly cameras: Readonly<Record<string, CameraData>>;
|
|
105
|
+
readonly lights: Readonly<Record<string, LightData>>;
|
|
106
|
+
readonly empties: Readonly<Record<string, EmptyData>>;
|
|
107
|
+
readonly sceneCamera: string | null;
|
|
108
|
+
readonly renderAspect: number;
|
|
109
|
+
/** The camera a camera view is looking through, which is not drawn: the view's frame is its
|
|
110
|
+
* border, and while a locked view is navigated the camera follows it a round trip behind. */
|
|
111
|
+
readonly lookingThrough: string | null;
|
|
112
|
+
/** Each light datablock's clip start and cut-off distance, for the direction line. */
|
|
113
|
+
readonly lightDistances: Readonly<Record<string, readonly [number, number]>>;
|
|
114
|
+
/** The presented object an extra stands for, which a click on its drawing selects. */
|
|
115
|
+
readonly presented: (name: string) => THREE.Object3D | null;
|
|
116
|
+
/** A frame image's texture and its size in pixels (0 while it is still decoding). */
|
|
117
|
+
readonly image: (name: string) => { readonly texture: THREE.Texture; readonly width: number; readonly height: number } | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* AN OVERLAY IS NEVER AN OCCLUDER. A pass that draws the whole scene under one override material
|
|
122
|
+
* (the selection outline's depth pass, `OutlineEffect`'s `DepthPass`) would have every overlay
|
|
123
|
+
* part write depth — the lines are meshes (`LineSegments2`), and an image empty drawn behind the
|
|
124
|
+
* scene is a quad in front of what it is behind — and so hide the outline of a selected object
|
|
125
|
+
* they stand before, which Blender's overlay never does. So a part leaves any draw whose scene
|
|
126
|
+
* carries an override material: collapsed before three reads its matrix, and put back as soon
|
|
127
|
+
* as that draw is done.
|
|
128
|
+
*/
|
|
129
|
+
function standDownInOverridePasses(object: THREE.Object3D): void {
|
|
130
|
+
if (!(object as THREE.Mesh).isMesh || object.userData['volterOverrideStandDown']) return;
|
|
131
|
+
object.userData['volterOverrideStandDown'] = true;
|
|
132
|
+
const own = object.onBeforeRender.bind(object);
|
|
133
|
+
const ownAfter = object.onAfterRender.bind(object);
|
|
134
|
+
const placed = new THREE.Matrix4();
|
|
135
|
+
let collapsed = false;
|
|
136
|
+
object.onBeforeRender = (renderer, scene, camera, geometry, material, group) => {
|
|
137
|
+
own(renderer, scene, camera, geometry, material, group);
|
|
138
|
+
if (!(scene as THREE.Scene).overrideMaterial) return;
|
|
139
|
+
placed.copy(object.matrixWorld);
|
|
140
|
+
object.matrixWorld.makeScale(0, 0, 0);
|
|
141
|
+
collapsed = true;
|
|
142
|
+
};
|
|
143
|
+
// Put back the moment that draw is done, so a pick between frames reads the part where it is.
|
|
144
|
+
object.onAfterRender = (renderer, scene, camera, geometry, material, group) => {
|
|
145
|
+
if (collapsed) {
|
|
146
|
+
object.matrixWorld.copy(placed);
|
|
147
|
+
collapsed = false;
|
|
148
|
+
}
|
|
149
|
+
ownAfter(renderer, scene, camera, geometry, material, group);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class ExtrasOverlay {
|
|
154
|
+
/** In Blender's frame; the view gives it the Blender → stage permutation. */
|
|
155
|
+
readonly cameras = new THREE.Group();
|
|
156
|
+
readonly lights = new THREE.Group();
|
|
157
|
+
readonly empties = new THREE.Group();
|
|
158
|
+
private readonly lineMaterials = new Map<string, LineMaterial>();
|
|
159
|
+
/** Each object's drawing and what it was drawn from, so an unchanged object keeps its. */
|
|
160
|
+
private readonly drawn = new Map<string, { key: string; parts: THREE.Object3D[] }>();
|
|
161
|
+
private readonly fillMaterials = new Map<number, THREE.MeshBasicMaterial>();
|
|
162
|
+
private readonly scratch = new THREE.Vector3();
|
|
163
|
+
private readonly size = new THREE.Vector2();
|
|
164
|
+
private readonly parentQuat = new THREE.Quaternion();
|
|
165
|
+
|
|
166
|
+
constructor() {
|
|
167
|
+
this.cameras.name = 'BlenderCameras';
|
|
168
|
+
this.lights.name = 'BlenderLights';
|
|
169
|
+
this.empties.name = 'BlenderEmpties';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
apply(input: ExtrasInput): void {
|
|
173
|
+
const seen = new Set<string>();
|
|
174
|
+
for (const object of input.objects) {
|
|
175
|
+
const color = object.selected
|
|
176
|
+
? object.name === input.active
|
|
177
|
+
? THEME.active
|
|
178
|
+
: THEME.selected
|
|
179
|
+
: object.type === 'CAMERA'
|
|
180
|
+
? THEME.camera
|
|
181
|
+
: object.type === 'LIGHT'
|
|
182
|
+
? THEME.light
|
|
183
|
+
: THEME.empty;
|
|
184
|
+
const camera =
|
|
185
|
+
object.type === 'CAMERA' && object.name !== input.lookingThrough ? input.cameras[object.name] : undefined;
|
|
186
|
+
const light = object.type === 'LIGHT' && object.light ? input.lights[object.light] : undefined;
|
|
187
|
+
const empty = object.type === 'EMPTY' ? input.empties[object.name] : undefined;
|
|
188
|
+
if (!camera && !light && !empty) continue;
|
|
189
|
+
seen.add(object.name);
|
|
190
|
+
const distances = light && object.light ? (input.lightDistances[object.light] ?? null) : null;
|
|
191
|
+
const picture = empty?.image?.name ? input.image(empty.image.name) : null;
|
|
192
|
+
const key = JSON.stringify([
|
|
193
|
+
picture ? [picture.texture.uuid, picture.width, picture.height] : null,
|
|
194
|
+
object.matrix,
|
|
195
|
+
color,
|
|
196
|
+
camera,
|
|
197
|
+
light,
|
|
198
|
+
distances,
|
|
199
|
+
empty,
|
|
200
|
+
camera ? [object.name === input.sceneCamera, input.renderAspect] : null,
|
|
201
|
+
]);
|
|
202
|
+
const previous = this.drawn.get(object.name);
|
|
203
|
+
const stands = input.presented(object.name);
|
|
204
|
+
if (previous?.key === key) {
|
|
205
|
+
for (const part of previous.parts) part.userData['volterPicksAs'] = stands;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (previous) this.remove(previous.parts);
|
|
209
|
+
const matrix = new THREE.Matrix4().set(...(object.matrix.flat() as Parameters<THREE.Matrix4['set']>));
|
|
210
|
+
const group = camera ? this.cameras : light ? this.lights : this.empties;
|
|
211
|
+
const before = new Set(group.children);
|
|
212
|
+
if (camera) this.camera(camera, matrix, color, object.name === input.sceneCamera, input.renderAspect);
|
|
213
|
+
else if (light) this.light(light, matrix, color, distances);
|
|
214
|
+
else if (empty) this.empty(empty, matrix, color, picture);
|
|
215
|
+
const parts = group.children.filter((child) => !before.has(child));
|
|
216
|
+
for (const part of parts) {
|
|
217
|
+
part.userData['volterPicksAs'] = stands;
|
|
218
|
+
part.traverse(standDownInOverridePasses);
|
|
219
|
+
}
|
|
220
|
+
this.drawn.set(object.name, { key, parts });
|
|
221
|
+
}
|
|
222
|
+
for (const [name, entry] of this.drawn)
|
|
223
|
+
if (!seen.has(name)) {
|
|
224
|
+
this.remove(entry.parts);
|
|
225
|
+
this.drawn.delete(name);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private remove(parts: readonly THREE.Object3D[]): void {
|
|
230
|
+
for (const part of parts) {
|
|
231
|
+
part.removeFromParent();
|
|
232
|
+
(part as THREE.Mesh).geometry?.dispose();
|
|
233
|
+
if (part.userData['volterOwnMaterial']) ((part as THREE.Mesh).material as THREE.Material).dispose();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `object_sync_extras` in `overlay_camera.hh`, with `BKE_camera_view_frame_ex`. */
|
|
238
|
+
private camera(camera: CameraData, matrix: THREE.Matrix4, color: number, active: boolean, renderAspect: number): void {
|
|
239
|
+
const drawsize = camera.display_size;
|
|
240
|
+
const horizontal = camera.sensor_fit === 'AUTO' ? renderAspect >= 1 : camera.sensor_fit === 'HORIZONTAL';
|
|
241
|
+
const aspect = horizontal ? [1, 1 / renderAspect] : [renderAspect, 1];
|
|
242
|
+
let half: number;
|
|
243
|
+
let depth: number;
|
|
244
|
+
let fac: [number, number];
|
|
245
|
+
let shift: [number, number];
|
|
246
|
+
if (camera.type === 'ORTHO') {
|
|
247
|
+
fac = [0.5 * camera.ortho_scale * aspect[0]!, 0.5 * camera.ortho_scale * aspect[1]!];
|
|
248
|
+
shift = [camera.shift_x * camera.ortho_scale, camera.shift_y * camera.ortho_scale];
|
|
249
|
+
depth = -drawsize;
|
|
250
|
+
half = 0.5 * camera.ortho_scale;
|
|
251
|
+
} else {
|
|
252
|
+
const halfSensor = 0.5 * (camera.sensor_fit === 'VERTICAL' ? camera.sensor_height : camera.sensor_width);
|
|
253
|
+
// A scaled camera draws larger: the overlay hands the frame function `1 / scale` and scales
|
|
254
|
+
// its corners back, which leaves the size divided by the mean of `1 / scale`.
|
|
255
|
+
const scale = new THREE.Vector3().setFromMatrixScale(matrix);
|
|
256
|
+
if (scale.x === 0 || scale.y === 0 || scale.z === 0) return;
|
|
257
|
+
half = drawsize / 2 / ((1 / scale.x + 1 / scale.y + 1 / scale.z) / 3);
|
|
258
|
+
depth = (half * camera.lens) / -halfSensor;
|
|
259
|
+
fac = [half * aspect[0]!, half * aspect[1]!];
|
|
260
|
+
shift = [camera.shift_x * half * 2, camera.shift_y * half * 2];
|
|
261
|
+
}
|
|
262
|
+
const corners = [
|
|
263
|
+
[shift[0] + fac[0], shift[1] + fac[1], depth],
|
|
264
|
+
[shift[0] + fac[0], shift[1] - fac[1], depth],
|
|
265
|
+
[shift[0] - fac[0], shift[1] - fac[1], depth],
|
|
266
|
+
[shift[0] - fac[0], shift[1] + fac[1], depth],
|
|
267
|
+
];
|
|
268
|
+
const lines: Segments = [];
|
|
269
|
+
for (let i = 0; i < 4; i++) {
|
|
270
|
+
seg(lines, corners[i]!, corners[(i + 1) % 4]!);
|
|
271
|
+
seg(lines, corners[i]!, [0, 0, 0]);
|
|
272
|
+
}
|
|
273
|
+
// The triangle over the frame: `0.7` and `0.1` of the drawn size, in the frame's plane.
|
|
274
|
+
const size = 0.7 * half;
|
|
275
|
+
const margin = 0.1 * half;
|
|
276
|
+
const base = shift[1] + fac[1] + margin;
|
|
277
|
+
const tri = [
|
|
278
|
+
[shift[0] - size, base, depth],
|
|
279
|
+
[shift[0] + size, base, depth],
|
|
280
|
+
[shift[0], base + size, depth],
|
|
281
|
+
];
|
|
282
|
+
const pose = unscaled(matrix);
|
|
283
|
+
this.cameras.add(this.lines(lines, color, 1, pose));
|
|
284
|
+
let triangle: THREE.Object3D;
|
|
285
|
+
if (active) {
|
|
286
|
+
const geometry = new THREE.BufferGeometry();
|
|
287
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(tri.flat(), 3));
|
|
288
|
+
triangle = new THREE.Mesh(geometry, this.fill(color));
|
|
289
|
+
triangle.matrixAutoUpdate = false;
|
|
290
|
+
triangle.matrix.copy(pose);
|
|
291
|
+
} else {
|
|
292
|
+
const wire: Segments = [];
|
|
293
|
+
for (let i = 0; i < 3; i++) seg(wire, tri[i]!, tri[(i + 1) % 3]!);
|
|
294
|
+
triangle = this.lines(wire, color, 1, pose);
|
|
295
|
+
}
|
|
296
|
+
// Hidden from the camera's own eye: the triangle is the one part a camera view would show.
|
|
297
|
+
const eye = new THREE.Vector3();
|
|
298
|
+
const own = triangle.onBeforeRender.bind(triangle);
|
|
299
|
+
triangle.onBeforeRender = (renderer, scene, camera, geometry, material, group) => {
|
|
300
|
+
eye.setFromMatrixPosition(pose).applyMatrix4(this.cameras.matrixWorld);
|
|
301
|
+
const seen = camera.getWorldPosition(this.scratch).distanceToSquared(eye) > 1e-8;
|
|
302
|
+
triangle.matrix.copy(pose);
|
|
303
|
+
if (!seen) triangle.matrix.scale(new THREE.Vector3(0, 0, 0));
|
|
304
|
+
triangle.updateMatrixWorld(true);
|
|
305
|
+
own(renderer, scene, camera, geometry, material, group);
|
|
306
|
+
};
|
|
307
|
+
this.cameras.add(triangle);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `overlay_light.hh` with its icon, ground line and type shapes. */
|
|
311
|
+
private light(light: LightData, matrix: THREE.Matrix4, color: number, distances: readonly [number, number] | null): void {
|
|
312
|
+
const origin = new THREE.Vector3().setFromMatrixPosition(matrix);
|
|
313
|
+
// The ground line, from the light straight down to the floor, and its mark there.
|
|
314
|
+
const ground: Segments = [];
|
|
315
|
+
seg(ground, [origin.x, origin.y, origin.z], [origin.x, origin.y, 0]);
|
|
316
|
+
this.lights.add(this.lines(ground, THEME.light, THEME.lightAlpha, new THREE.Matrix4()));
|
|
317
|
+
const mark: Segments = [];
|
|
318
|
+
loop(mark, ring(1.35, 4));
|
|
319
|
+
this.lights.add(this.screen(mark, THEME.light, THEME.lightAlpha, new THREE.Vector3(origin.x, origin.y, 0)));
|
|
320
|
+
// The icon: the diamond and dashed ring (inner), the dashed outer ring, a sun's rays.
|
|
321
|
+
const icon: Segments = [];
|
|
322
|
+
const r = 9;
|
|
323
|
+
loop(icon, ring(r * 0.3, 4));
|
|
324
|
+
loop(icon, ring(r, 16), 0, true);
|
|
325
|
+
loop(icon, ring(r * 1.33, 20), 0, true);
|
|
326
|
+
if (light.type === 'SUN')
|
|
327
|
+
for (const [x, y] of ring(r, 8)) {
|
|
328
|
+
seg(icon, [x * 1.6, y * 1.6, 0], [x * 1.9, y * 1.9, 0]);
|
|
329
|
+
seg(icon, [x * 2.2, y * 2.2, 0], [x * 2.5, y * 2.5, 0]);
|
|
330
|
+
}
|
|
331
|
+
this.lights.add(this.screen(icon, color, 1, origin));
|
|
332
|
+
// The shape the light has in the world.
|
|
333
|
+
const pose = unscaled(matrix);
|
|
334
|
+
const world: Segments = [];
|
|
335
|
+
if (light.type === 'SUN') seg(world, [0, 0, 0], [0, 0, -20]);
|
|
336
|
+
else if (light.type === 'AREA') {
|
|
337
|
+
const sx = (light.size ?? 1) / 2;
|
|
338
|
+
const sy = (light.shape === 'RECTANGLE' || light.shape === 'ELLIPSE' ? (light.size_y ?? light.size ?? 1) : (light.size ?? 1)) / 2;
|
|
339
|
+
if (light.shape === 'DISK' || light.shape === 'ELLIPSE')
|
|
340
|
+
loop(world, ring(1, 32).map(([x, y]) => [x * sx, y * sy]));
|
|
341
|
+
else loop(world, [[sx, sy], [-sx, sy], [-sx, -sy], [sx, -sy]]);
|
|
342
|
+
}
|
|
343
|
+
if (world.length > 0) this.lights.add(this.lines(world, color, 1, matrix.clone()));
|
|
344
|
+
// The direction line of a spot or an area light (`light_append_direction_line`): down its
|
|
345
|
+
// -Z from the clip start to the cut-off, in world units whatever its scale, with a diamond
|
|
346
|
+
// at each end.
|
|
347
|
+
if (distances && (light.type === 'SPOT' || light.type === 'AREA')) {
|
|
348
|
+
const along = new THREE.Vector3(0, 0, -1).transformDirection(matrix);
|
|
349
|
+
const at = (distance: number) => origin.clone().addScaledVector(along, distance);
|
|
350
|
+
const [start, end] = distances;
|
|
351
|
+
const line: Segments = [];
|
|
352
|
+
seg(line, at(start).toArray(), at(end).toArray());
|
|
353
|
+
this.lights.add(this.lines(line, color, 1, new THREE.Matrix4()));
|
|
354
|
+
for (const distance of [start, end]) {
|
|
355
|
+
const diamond: Segments = [];
|
|
356
|
+
loop(diamond, ring(1.2, 4));
|
|
357
|
+
this.lights.add(this.screen(diamond, color, 1, at(distance)));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// THE SPOT'S CONE, ten units long (the overlay rescales the light's matrix by 10): the cap
|
|
361
|
+
// at the cone's angle, the blend's circle inside it, and of the cone's 32 generators only
|
|
362
|
+
// those on its silhouette from where it is seen, chosen each draw as the shader chooses them.
|
|
363
|
+
if (light.type === 'SPOT') {
|
|
364
|
+
const half = (light.spot_size ?? Math.PI / 4) / 2;
|
|
365
|
+
const a = Math.cos(half);
|
|
366
|
+
const b = light.spot_blend ?? 0.15;
|
|
367
|
+
const c = a * b - a - b;
|
|
368
|
+
const blend = Math.sqrt((a * a - a * a * c * c) / (c * c - a * a * c * c));
|
|
369
|
+
const sine = Math.sqrt(1 - a * a);
|
|
370
|
+
const cone = unscaled(matrix).multiply(new THREE.Matrix4().makeScale(10, 10, 10));
|
|
371
|
+
const caps: Segments = [];
|
|
372
|
+
loop(caps, ring(sine, 32), -a);
|
|
373
|
+
loop(caps, ring(sine * blend, 32), -a);
|
|
374
|
+
this.lights.add(this.lines(caps, color, 1, cone.clone()));
|
|
375
|
+
this.lights.add(this.silhouette(sine, a, color, cone));
|
|
376
|
+
}
|
|
377
|
+
if ((light.type === 'POINT' || light.type === 'SPOT') && (light.radius ?? 0) > 0) {
|
|
378
|
+
const circle: Segments = [];
|
|
379
|
+
loop(circle, ring(light.radius!, 32));
|
|
380
|
+
this.lights.add(this.facing(circle, color, new THREE.Vector3().setFromMatrixPosition(pose)));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `overlay_empty.hh`'s shapes, at the empty's display size in its own frame. */
|
|
385
|
+
private empty(
|
|
386
|
+
empty: EmptyData,
|
|
387
|
+
matrix: THREE.Matrix4,
|
|
388
|
+
color: number,
|
|
389
|
+
picture: { readonly texture: THREE.Texture; readonly width: number; readonly height: number } | null,
|
|
390
|
+
): void {
|
|
391
|
+
const s = empty.size;
|
|
392
|
+
const lines: Segments = [];
|
|
393
|
+
const circle = (plane: 'xy' | 'xz' | 'yz', n: number) => {
|
|
394
|
+
const points = ring(1, n);
|
|
395
|
+
for (let i = 0; i < n; i++) {
|
|
396
|
+
const [ax, ay] = points[i]!;
|
|
397
|
+
const [bx, by] = points[(i + 1) % n]!;
|
|
398
|
+
const at = (x: number, y: number) => (plane === 'xy' ? [x, y, 0] : plane === 'xz' ? [x, 0, y] : [0, x, y]);
|
|
399
|
+
seg(lines, at(ax, ay), at(bx, by));
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
switch (empty.display) {
|
|
403
|
+
case 'PLAIN_AXES':
|
|
404
|
+
seg(lines, [0, -1, 0], [0, 1, 0]);
|
|
405
|
+
seg(lines, [-1, 0, 0], [1, 0, 0]);
|
|
406
|
+
seg(lines, [0, 0, -1], [0, 0, 1]);
|
|
407
|
+
break;
|
|
408
|
+
case 'SINGLE_ARROW': {
|
|
409
|
+
seg(lines, [0, 0, 0], [0, 0, 0.75]);
|
|
410
|
+
for (const [x, y] of [[0.035, 0.035], [-0.035, 0.035], [-0.035, -0.035], [0.035, -0.035]] as const) {
|
|
411
|
+
seg(lines, [0, 0, 1], [x, y, 0.75]);
|
|
412
|
+
}
|
|
413
|
+
loop(lines, [[0.035, 0.035], [-0.035, 0.035], [-0.035, -0.035], [0.035, -0.035]], 0.75);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
case 'CUBE':
|
|
417
|
+
for (const [a, b] of CUBE_EDGES) seg(lines, CUBE[a]!, CUBE[b]!);
|
|
418
|
+
break;
|
|
419
|
+
case 'CIRCLE':
|
|
420
|
+
circle('xz', 64);
|
|
421
|
+
break;
|
|
422
|
+
case 'SPHERE':
|
|
423
|
+
circle('xy', 32);
|
|
424
|
+
circle('xz', 32);
|
|
425
|
+
circle('yz', 32);
|
|
426
|
+
break;
|
|
427
|
+
case 'CONE': {
|
|
428
|
+
const points = ring(1, 8);
|
|
429
|
+
for (let i = 0; i < 8; i++) {
|
|
430
|
+
const [x, y] = points[i]!;
|
|
431
|
+
const [nx, ny] = points[(i + 1) % 8]!;
|
|
432
|
+
seg(lines, [x, 0, y], [0, 2, 0]);
|
|
433
|
+
seg(lines, [x, 0, y], [nx, 0, ny]);
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case 'ARROWS':
|
|
438
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
439
|
+
const end = [0, 0, 0];
|
|
440
|
+
end[axis] = 1;
|
|
441
|
+
seg(lines, [0, 0, 0], end);
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
case 'IMAGE':
|
|
445
|
+
this.imageEmpty(empty, matrix, color, picture);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const pose = matrix.clone().multiply(new THREE.Matrix4().makeScale(s, s, s));
|
|
449
|
+
this.empties.add(this.lines(lines, color, 1, pose));
|
|
450
|
+
if (empty.display === 'ARROWS') this.axisNames(matrix, s, color);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A spot cone's silhouette generators, re-chosen before each draw for the camera drawing it
|
|
454
|
+
* (`overlay_extra_vert.glsl`, `VCLASS_LIGHT_SPOT_CONE`: a generator is kept where the two
|
|
455
|
+
* faces beside it face opposite ways from the view). */
|
|
456
|
+
private silhouette(sine: number, cosine: number, color: number, cone: THREE.Matrix4): LineSegments2 {
|
|
457
|
+
// All 32 generators, allocated once and rewritten in place: three uploads a geometry before
|
|
458
|
+
// its draw hook runs, so a new one set there would never reach the GPU. A generator off the
|
|
459
|
+
// silhouette collapses to the apex. (The rewrite reaches the next frame's upload.)
|
|
460
|
+
const lines = this.lines(new Array<number>(32 * 6).fill(0), color, 1, cone);
|
|
461
|
+
lines.frustumCulled = false;
|
|
462
|
+
const buffer = (lines.geometry.getAttribute('instanceStart') as THREE.InterleavedBufferAttribute).data;
|
|
463
|
+
const points = ring(1, 32).map(([x, y]) => new THREE.Vector3(x * sine, y * sine, -cosine));
|
|
464
|
+
const eye = new THREE.Vector3();
|
|
465
|
+
const inverse = new THREE.Matrix4();
|
|
466
|
+
const n0 = new THREE.Vector3();
|
|
467
|
+
const n1 = new THREE.Vector3();
|
|
468
|
+
const toward = new THREE.Vector3();
|
|
469
|
+
this.onDraw(lines, (_renderer, camera) => {
|
|
470
|
+
inverse.copy(lines.matrixWorld).invert();
|
|
471
|
+
const orthographic = (camera as THREE.OrthographicCamera).isOrthographicCamera;
|
|
472
|
+
// The view, in the cone's own frame: toward the eye, or against the view's direction.
|
|
473
|
+
if (orthographic) camera.getWorldDirection(eye).negate().transformDirection(inverse);
|
|
474
|
+
else eye.setFromMatrixPosition(camera.matrixWorld).applyMatrix4(inverse);
|
|
475
|
+
const array = buffer.array as Float32Array;
|
|
476
|
+
let changed = false;
|
|
477
|
+
for (let i = 0; i < 32; i++) {
|
|
478
|
+
const p = points[i]!;
|
|
479
|
+
// Each face spans the apex, this generator and a neighbour.
|
|
480
|
+
n0.crossVectors(p, points[(i + 31) % 32]!);
|
|
481
|
+
n1.crossVectors(points[(i + 1) % 32]!, p);
|
|
482
|
+
if (orthographic) toward.copy(eye);
|
|
483
|
+
else toward.copy(eye).sub(p);
|
|
484
|
+
const kept = n0.dot(toward) > 0 !== n1.dot(toward) > 0;
|
|
485
|
+
if ((array[i * 6 + 3] !== 0 || array[i * 6 + 5] !== 0) !== kept) changed = true;
|
|
486
|
+
array.set([0, 0, 0, kept ? p.x : 0, kept ? p.y : 0, kept ? p.z : 0], i * 6);
|
|
487
|
+
}
|
|
488
|
+
// The rewrite reaches the next draw; a stage that draws on demand has to be asked for it,
|
|
489
|
+
// or a view that stopped moving keeps the silhouette of the step before.
|
|
490
|
+
if (changed) {
|
|
491
|
+
buffer.needsUpdate = true;
|
|
492
|
+
presenterChanged();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
return lines;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* AN IMAGE EMPTY (`overlay_empty.hh` `image_sync`): its frame in the wire colour, and the picture
|
|
500
|
+
* on a quad as wide as the display size along the image's longer side, moved by its offset
|
|
501
|
+
* (`(offset · 2 + 1)` of the scaled axes; the default −0.5 centres it), unlit. Whether either
|
|
502
|
+
* shows is Blender's per view: the projection flags for both, then the side (by the object's Z
|
|
503
|
+
* against the view) and the axis-aligned-only flag for the picture. In front of or behind the
|
|
504
|
+
* scene when its depth says so. A picture still decoding shows its frame alone.
|
|
505
|
+
*/
|
|
506
|
+
private imageEmpty(
|
|
507
|
+
empty: EmptyData,
|
|
508
|
+
matrix: THREE.Matrix4,
|
|
509
|
+
color: number,
|
|
510
|
+
picture: { readonly texture: THREE.Texture; readonly width: number; readonly height: number } | null,
|
|
511
|
+
): void {
|
|
512
|
+
const settings = empty.image;
|
|
513
|
+
if (!settings) return;
|
|
514
|
+
const width = picture?.width ?? 0;
|
|
515
|
+
const height = picture?.height ?? 0;
|
|
516
|
+
const aspect = width > 0 && height > 0 ? (width >= height ? [1, height / width] : [width / height, 1]) : [1, 1];
|
|
517
|
+
const x = new THREE.Vector3();
|
|
518
|
+
const y = new THREE.Vector3();
|
|
519
|
+
const z = new THREE.Vector3();
|
|
520
|
+
matrix.extractBasis(x, y, z);
|
|
521
|
+
x.multiplyScalar(aspect[0]! * 0.5 * empty.size);
|
|
522
|
+
y.multiplyScalar(aspect[1]! * 0.5 * empty.size);
|
|
523
|
+
const origin = new THREE.Vector3()
|
|
524
|
+
.setFromMatrixPosition(matrix)
|
|
525
|
+
.addScaledVector(x, settings.offset[0] * 2 + 1)
|
|
526
|
+
.addScaledVector(y, settings.offset[1] * 2 + 1);
|
|
527
|
+
const pose = new THREE.Matrix4().makeBasis(x, y, z.clone()).setPosition(origin);
|
|
528
|
+
const frame: Segments = [];
|
|
529
|
+
loop(frame, [[-1, -1], [1, -1], [1, 1], [-1, 1]]);
|
|
530
|
+
const outline = this.lines(frame, color, 1, pose);
|
|
531
|
+
const normal = z.clone().normalize();
|
|
532
|
+
const eye = new THREE.Vector3();
|
|
533
|
+
const forward = new THREE.Vector3();
|
|
534
|
+
const shown = (camera: THREE.Camera, picture: boolean): boolean => {
|
|
535
|
+
const orthographic = (camera as THREE.OrthographicCamera).isOrthographicCamera === true;
|
|
536
|
+
if (orthographic ? !settings.orthographic : !settings.perspective) return false;
|
|
537
|
+
if (!picture) return true;
|
|
538
|
+
// In the stage's frame: this group carries the Blender → stage permutation.
|
|
539
|
+
const worldNormal = normal.clone().transformDirection(this.empties.matrixWorld);
|
|
540
|
+
camera.getWorldDirection(forward);
|
|
541
|
+
let dot: number;
|
|
542
|
+
let eps: number;
|
|
543
|
+
if (orthographic) {
|
|
544
|
+
dot = worldNormal.dot(forward.clone().negate());
|
|
545
|
+
eps = 1e-5;
|
|
546
|
+
} else {
|
|
547
|
+
const at = origin.clone().applyMatrix4(this.empties.matrixWorld);
|
|
548
|
+
dot = worldNormal.dot(eye.setFromMatrixPosition(camera.matrixWorld).sub(at));
|
|
549
|
+
eps = 0;
|
|
550
|
+
}
|
|
551
|
+
if (settings.side === 'FRONT' && dot < eps) return false;
|
|
552
|
+
if (settings.side === 'BACK' && dot > -eps) return false;
|
|
553
|
+
if (settings.axis_aligned) {
|
|
554
|
+
const projected = worldNormal.clone().projectOnPlane(forward);
|
|
555
|
+
if (projected.lengthSq() > 1e-5) return false;
|
|
556
|
+
}
|
|
557
|
+
return true;
|
|
558
|
+
};
|
|
559
|
+
const hideUnless = (part: THREE.Object3D, picture: boolean): void => {
|
|
560
|
+
const own = part.onBeforeRender.bind(part);
|
|
561
|
+
part.onBeforeRender = (renderer, scene, camera, geometry, material, group) => {
|
|
562
|
+
part.matrix.copy(pose);
|
|
563
|
+
if (!shown(camera, picture)) part.matrix.scale(new THREE.Vector3(0, 0, 0));
|
|
564
|
+
part.updateMatrixWorld(true);
|
|
565
|
+
own(renderer, scene, camera, geometry, material, group);
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
hideUnless(outline, false);
|
|
569
|
+
this.empties.add(outline);
|
|
570
|
+
if (!picture || (settings.opacity !== null && settings.opacity <= 0)) return;
|
|
571
|
+
// `overlay_image_frag.glsl`: the texel, un-premultiplied when the image stores it so, times the
|
|
572
|
+
// object's colour. Without Use Alpha, a texel under 5% alpha is cut out and the rest drawn
|
|
573
|
+
// opaque; with it, the picture blends by the texel's alpha times the object's. Behind the
|
|
574
|
+
// scene stays in the opaque list, drawn first (a transparent material would draw after every
|
|
575
|
+
// opaque mesh and, untested against depth, cover them), and blends there by its own function.
|
|
576
|
+
const blends = settings.opacity !== null;
|
|
577
|
+
const behind = settings.depth === 'BACK';
|
|
578
|
+
const material = new THREE.MeshBasicMaterial({
|
|
579
|
+
map: picture.texture,
|
|
580
|
+
side: THREE.DoubleSide,
|
|
581
|
+
transparent: blends && !behind,
|
|
582
|
+
opacity: settings.opacity ?? 1,
|
|
583
|
+
alphaTest: blends ? 0 : 0.05,
|
|
584
|
+
toneMapped: false,
|
|
585
|
+
depthTest: settings.depth === 'DEFAULT',
|
|
586
|
+
// Blender's blend pass tests depth and writes none (`images_blend_ps_`).
|
|
587
|
+
depthWrite: settings.depth === 'DEFAULT' && !blends,
|
|
588
|
+
});
|
|
589
|
+
material.color.setRGB(...settings.tint, THREE.LinearSRGBColorSpace);
|
|
590
|
+
if (blends && behind) {
|
|
591
|
+
material.blending = THREE.CustomBlending;
|
|
592
|
+
material.blendSrc = THREE.SrcAlphaFactor;
|
|
593
|
+
material.blendDst = THREE.OneMinusSrcAlphaFactor;
|
|
594
|
+
}
|
|
595
|
+
material.onBeforeCompile = (shader) => {
|
|
596
|
+
const unpremultiply = settings.premultiplied
|
|
597
|
+
? 'if ( sampledDiffuseColor.a > 0.0 && sampledDiffuseColor.a < 1.0 ) sampledDiffuseColor.rgb /= sampledDiffuseColor.a;\n'
|
|
598
|
+
: '';
|
|
599
|
+
shader.fragmentShader = shader.fragmentShader
|
|
600
|
+
.replace(
|
|
601
|
+
'#include <map_fragment>',
|
|
602
|
+
`vec4 sampledDiffuseColor = texture2D( map, vMapUv );\n${unpremultiply}diffuseColor *= sampledDiffuseColor;`,
|
|
603
|
+
)
|
|
604
|
+
.replace('#include <alphatest_fragment>', blends ? '#include <alphatest_fragment>' : '#include <alphatest_fragment>\ndiffuseColor.a = 1.0;');
|
|
605
|
+
};
|
|
606
|
+
material.customProgramCacheKey = () => `volter-blender-image:${settings.premultiplied}:${blends}`;
|
|
607
|
+
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
|
|
608
|
+
quad.matrixAutoUpdate = false;
|
|
609
|
+
quad.matrix.copy(pose);
|
|
610
|
+
quad.renderOrder = settings.depth === 'FRONT' ? 1000 : settings.depth === 'BACK' ? -1000 : 0;
|
|
611
|
+
// Its material is its own (the picture and its opacity), freed with the quad.
|
|
612
|
+
quad.userData['volterOwnMaterial'] = true;
|
|
613
|
+
hideUnless(quad, true);
|
|
614
|
+
this.empties.add(quad);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The arrows' X, Y and Z, drawn as lines facing the view at 1.25 of each axis. */
|
|
618
|
+
private axisNames(matrix: THREE.Matrix4, size: number, color: number): void {
|
|
619
|
+
const axes = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()];
|
|
620
|
+
matrix.extractBasis(axes[0]!, axes[1]!, axes[2]!);
|
|
621
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
622
|
+
const length = axes[axis]!.length() * size;
|
|
623
|
+
const at = new THREE.Vector3(0, 0, 0);
|
|
624
|
+
at.setComponent(axis, 1.25 * size);
|
|
625
|
+
at.applyMatrix4(matrix);
|
|
626
|
+
// The marker at the axis's end: six diamonds, one inside the next (`axis_marker`, filled
|
|
627
|
+
// by its layers), facing the view.
|
|
628
|
+
const end = new THREE.Vector3(0, 0, 0);
|
|
629
|
+
end.setComponent(axis, size);
|
|
630
|
+
end.applyMatrix4(matrix);
|
|
631
|
+
const marker: Segments = [];
|
|
632
|
+
for (let layer = 1; layer <= 6; layer++) loop(marker, ring(0.007 * ((4 * layer) / 6) * length, 4));
|
|
633
|
+
this.empties.add(this.facing(marker, color, end));
|
|
634
|
+
const name = AXIS_NAMES[axis]!;
|
|
635
|
+
const out: Segments = [];
|
|
636
|
+
for (let i = 0; i + 1 < name.length; i += 2) {
|
|
637
|
+
const [ax, ay] = name[i]!;
|
|
638
|
+
const [bx, by] = name[i + 1]!;
|
|
639
|
+
seg(out, [ax * 4 * length, ay * 4 * length, 0], [bx * 4 * length, by * 4 * length, 0]);
|
|
640
|
+
}
|
|
641
|
+
this.empties.add(this.facing(out, color, at));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private material(color: number, opacity: number): LineMaterial {
|
|
646
|
+
const key = `${color}:${opacity}`;
|
|
647
|
+
let material = this.lineMaterials.get(key);
|
|
648
|
+
if (!material) {
|
|
649
|
+
material = new LineMaterial({
|
|
650
|
+
color,
|
|
651
|
+
linewidth: LINE_WIDTH,
|
|
652
|
+
transparent: opacity < 1,
|
|
653
|
+
opacity,
|
|
654
|
+
depthTest: true,
|
|
655
|
+
depthWrite: opacity >= 1,
|
|
656
|
+
toneMapped: false,
|
|
657
|
+
});
|
|
658
|
+
this.lineMaterials.set(key, material);
|
|
659
|
+
}
|
|
660
|
+
return material;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private fill(color: number): THREE.MeshBasicMaterial {
|
|
664
|
+
let material = this.fillMaterials.get(color);
|
|
665
|
+
if (!material) {
|
|
666
|
+
material = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, toneMapped: false });
|
|
667
|
+
this.fillMaterials.set(color, material);
|
|
668
|
+
}
|
|
669
|
+
return material;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** World lines under `pose`. */
|
|
673
|
+
private lines(segments: Segments, color: number, opacity: number, pose: THREE.Matrix4): LineSegments2 {
|
|
674
|
+
const geometry = new LineSegmentsGeometry().setPositions(segments);
|
|
675
|
+
const lines = new LineSegments2(geometry, this.material(color, opacity));
|
|
676
|
+
lines.matrixAutoUpdate = false;
|
|
677
|
+
lines.matrix.copy(pose);
|
|
678
|
+
lines.computeLineDistances();
|
|
679
|
+
return lines;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Lines in UI pixels on a plane facing the view, at `at` (`VCLASS_SCREENSPACE`). */
|
|
683
|
+
private screen(segments: Segments, color: number, opacity: number, at: THREE.Vector3): LineSegments2 {
|
|
684
|
+
const lines = this.lines(segments, color, opacity, new THREE.Matrix4());
|
|
685
|
+
lines.matrixAutoUpdate = true;
|
|
686
|
+
lines.position.copy(at);
|
|
687
|
+
// Sized by its first draw: until then it has no size to be picked by, and no bounds to be
|
|
688
|
+
// culled by.
|
|
689
|
+
lines.scale.setScalar(0);
|
|
690
|
+
lines.frustumCulled = false;
|
|
691
|
+
this.onDraw(lines, (renderer, camera) => this.face(lines, renderer, camera, true));
|
|
692
|
+
return lines;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Lines in world units on a plane facing the view, at `at` (`VCLASS_SCREENALIGNED`). */
|
|
696
|
+
private facing(segments: Segments, color: number, at: THREE.Vector3): LineSegments2 {
|
|
697
|
+
const lines = this.lines(segments, color, 1, new THREE.Matrix4());
|
|
698
|
+
lines.matrixAutoUpdate = true;
|
|
699
|
+
lines.position.copy(at);
|
|
700
|
+
this.onDraw(lines, (renderer, camera) => this.face(lines, renderer, camera, false));
|
|
701
|
+
return lines;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Run `fit` before each draw, after `LineSegments2`'s own (which gives the material the
|
|
705
|
+
* viewport's resolution). */
|
|
706
|
+
private onDraw(lines: LineSegments2, fit: (renderer: THREE.WebGLRenderer, camera: THREE.Camera) => void): void {
|
|
707
|
+
// `LineSegments2`'s typings declare its draw hook with the renderer alone; it is Object3D's.
|
|
708
|
+
const target: THREE.Object3D = lines;
|
|
709
|
+
const own = target.onBeforeRender.bind(target);
|
|
710
|
+
target.onBeforeRender = (renderer, scene, camera, geometry, material, group) => {
|
|
711
|
+
fit(renderer, camera);
|
|
712
|
+
own(renderer, scene, camera, geometry, material, group);
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Turn `lines` to the view and, for a screen-space shape, size one unit to one UI pixel. */
|
|
717
|
+
private face(lines: THREE.Object3D, renderer: THREE.WebGLRenderer, camera: THREE.Camera, pixels: boolean): void {
|
|
718
|
+
lines.parent?.getWorldQuaternion(this.parentQuat).invert();
|
|
719
|
+
camera.getWorldQuaternion(lines.quaternion).premultiply(this.parentQuat);
|
|
720
|
+
let scale = 1;
|
|
721
|
+
if (pixels) {
|
|
722
|
+
renderer.getSize(this.size);
|
|
723
|
+
const height = Math.max(this.size.y, 1);
|
|
724
|
+
const at = this.scratch.setFromMatrixPosition(lines.matrixWorld);
|
|
725
|
+
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
|
|
726
|
+
const ortho = camera as THREE.OrthographicCamera;
|
|
727
|
+
scale = (ortho.top - ortho.bottom) / ortho.zoom / height;
|
|
728
|
+
} else {
|
|
729
|
+
const perspective = camera as THREE.PerspectiveCamera;
|
|
730
|
+
const depth = Math.max(-at.applyMatrix4(camera.matrixWorldInverse).z, 1e-6);
|
|
731
|
+
scale = (2 * depth * Math.tan(THREE.MathUtils.degToRad(perspective.fov) / 2)) / perspective.zoom / height;
|
|
732
|
+
}
|
|
733
|
+
// The parent carries the permutation, a rotation: its scale is 1.
|
|
734
|
+
}
|
|
735
|
+
lines.scale.setScalar(scale);
|
|
736
|
+
lines.updateMatrixWorld(true);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
dispose(): void {
|
|
740
|
+
for (const entry of this.drawn.values()) this.remove(entry.parts);
|
|
741
|
+
this.drawn.clear();
|
|
742
|
+
for (const material of this.lineMaterials.values()) material.dispose();
|
|
743
|
+
for (const material of this.fillMaterials.values()) material.dispose();
|
|
744
|
+
this.lineMaterials.clear();
|
|
745
|
+
this.fillMaterials.clear();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The object's rotation and position, its scale taken out (`overlay_camera.hh` normalises). */
|
|
750
|
+
function unscaled(matrix: THREE.Matrix4): THREE.Matrix4 {
|
|
751
|
+
const position = new THREE.Vector3();
|
|
752
|
+
const quaternion = new THREE.Quaternion();
|
|
753
|
+
matrix.decompose(position, quaternion, new THREE.Vector3());
|
|
754
|
+
return new THREE.Matrix4().compose(position, quaternion, new THREE.Vector3(1, 1, 1));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const CUBE: readonly (readonly number[])[] = [
|
|
758
|
+
[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
|
|
759
|
+
[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1],
|
|
760
|
+
];
|
|
761
|
+
const CUBE_EDGES: readonly (readonly [number, number])[] = [
|
|
762
|
+
[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7],
|
|
763
|
+
];
|
|
764
|
+
|
|
765
|
+
/** The arrows' letters (`overlay_shape.cc`, `x/y/z_axis_name`), as line pairs. */
|
|
766
|
+
const AXIS_NAMES: readonly (readonly (readonly [number, number])[])[] = [
|
|
767
|
+
[[0.9, 1], [-1, -1], [-0.9, 1], [1, -1]].map(([x, y]) => [x! * 0.0215, y! * 0.025] as const),
|
|
768
|
+
[[-1, 1], [0, -0.1], [1, 1], [0, -0.1], [0, -0.1], [0, -1]].map(([x, y]) => [x! * 0.0175, y! * 0.025] as const),
|
|
769
|
+
[
|
|
770
|
+
[-0.95, 1], [0.95, 1], [0.95, 1], [0.95, 0.9], [0.95, 0.9], [-1, -0.9], [-1, -0.9], [-1, -1], [-1, -1], [1, -1],
|
|
771
|
+
].map(([x, y]) => [x! * 0.02, y! * 0.025] as const),
|
|
772
|
+
];
|