@maxenve/ren-three 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +307 -2
- package/package.json +51 -4
- package/src/index.js +1633 -0
- package/src/surface.js +204 -0
- package/types/index.d.ts +473 -0
- package/types/surface.d.ts +56 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
// ren-three: the three.js renderer for ren-core, a Host (ren-core/host) over a
|
|
2
|
+
// three.js scene graph.
|
|
3
|
+
//
|
|
4
|
+
// ren-core's portable primitives (View, Label, Edit, Action, …) render here too,
|
|
5
|
+
// through ren-canv (surface.js): placed in the scene itself they lie over the
|
|
6
|
+
// canvas; placed in a three.js object they are a panel in it. An
|
|
7
|
+
// application of primitives alone runs on ren-three as on ren-dom or
|
|
8
|
+
// ren-skia, and the two mix freely.
|
|
9
|
+
//
|
|
10
|
+
// A lowercase element is the three.js class of the same name, capitalized:
|
|
11
|
+
// `<mesh>` is a Mesh, `<sphereGeometry params={[1, 24, 16]}>` a
|
|
12
|
+
// SphereGeometry, `<meshPhongMaterial shininess={60}>` a MeshPhongMaterial.
|
|
13
|
+
// `<existing of={…}>` places an object made elsewhere. A host node is
|
|
14
|
+
// the three.js object itself (a `ref` receives it), with one record of ren-core's
|
|
15
|
+
// own under `__ren`. Geometries, materials and fog go into the parent's
|
|
16
|
+
// property of that name; `slot="…"` names any other. Properties set by
|
|
17
|
+
// name, with a dash for a path (`position-x`), and through `set`/`copy` for
|
|
18
|
+
// vectors, eulers, colors and quaternions (`position={[0, 1, 0]}`,
|
|
19
|
+
// `scale={2}`, `color="#f80"`).
|
|
20
|
+
//
|
|
21
|
+
// Rendering is on demand: a flush that changed the scene renders one frame,
|
|
22
|
+
// on the next animation frame. `$animate(fn)` in a component runs `fn` every
|
|
23
|
+
// frame while the component lives, and the loop renders each of those
|
|
24
|
+
// frames. Pointer events are raycast from the camera: `onClick`,
|
|
25
|
+
// `onPointerDown`, `onPointerMove`, `onPointerOver`, `onPointerOut`, … on
|
|
26
|
+
// any object, bubbling to its ancestors.
|
|
27
|
+
//
|
|
28
|
+
// three.js is injected, as CanvasKit is for ren-skia:
|
|
29
|
+
// `$three(THREE, options)` with `import * as THREE from 'three'`.
|
|
30
|
+
//
|
|
31
|
+
// Worker mode: three.js and the scene stay on the main thread, where the
|
|
32
|
+
// renderer is; the worker holds only the application and its components, as
|
|
33
|
+
// with ren-dom and ren-skia. The worker serves the view with
|
|
34
|
+
// `serve(view, { construct: CONSTRUCT })`, so constructor `params` travel
|
|
35
|
+
// with the elements, and `$animate` runs there on the worker's own frames,
|
|
36
|
+
// changing signals whose writes cross like any update. Pointer events cross
|
|
37
|
+
// with their canvas coordinates; refs in the worker are handles.
|
|
38
|
+
|
|
39
|
+
import { mount, $surface, $active } from 'ren-core/host';
|
|
40
|
+
import { $owner, $cleanup, $deliver, $flush } from 'ren-core';
|
|
41
|
+
import { Item, Faces } from 'ren-canv';
|
|
42
|
+
|
|
43
|
+
export { loadLayout, loadShaper, remoteLayout } from 'ren-canv';
|
|
44
|
+
import { Surface } from './surface.js';
|
|
45
|
+
|
|
46
|
+
/** @typedef {import('ren-core/host').Host} Host */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The three.js namespace (`import * as THREE from 'three'`): its classes by
|
|
50
|
+
* name. Typed loosely, so ren-three needs no three.js declarations.
|
|
51
|
+
* @typedef {Record<string, any>} Kit
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What ren-three draws with: a THREE.WebGLRenderer, or anything with its
|
|
56
|
+
* render/setSize shape (tests pass a stub).
|
|
57
|
+
* @typedef {object} Drawer
|
|
58
|
+
* @property {(scene: any, camera: any) => void} render
|
|
59
|
+
* @property {(width: number, height: number, style?: boolean) => void} setSize
|
|
60
|
+
* @property {((ratio: number) => void)=} setPixelRatio
|
|
61
|
+
* @property {(() => void)=} dispose
|
|
62
|
+
* @property {(() => void)=} forceContextLoss Loses the WebGL context now (a three.js WebGLRenderer has it).
|
|
63
|
+
* @property {any=} domElement its canvas
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} Options
|
|
68
|
+
* @property {number=} width The canvas width in CSS pixels; by default the host element's, followed as it resizes (300 without one).
|
|
69
|
+
* @property {number=} height The canvas height in CSS pixels; as width (150 without one).
|
|
70
|
+
* @property {number=} scale The device pixel ratio (the window's by default).
|
|
71
|
+
* @property {Drawer | ((canvas: any) => Drawer)=} renderer What draws: a WebGLRenderer made on the canvas by default (antialiased), or one given, or a function making it from the canvas.
|
|
72
|
+
* @property {any=} camera The camera: a three.js camera, or `{ fov, near, far, position }` for a PerspectiveCamera (50°, 0.1, 1000, at [0, 0, 5] by default). A `<perspectiveCamera main>` in the view replaces it.
|
|
73
|
+
* @property {(number | string)=} background The scene's background color.
|
|
74
|
+
* @property {('frame' | false)=} schedule `frame` (default) renders on animation frames; false renders only when `frame()` is called (tests, a host that drives its own loop).
|
|
75
|
+
* @property {(ArrayBuffer | Uint8Array)=} font The bytes of the typeface the primitives are measured and drawn with (needed once a primitive renders).
|
|
76
|
+
* @property {(ArrayBuffer | Uint8Array)=} mono The bytes of a monospace typeface: text whose `fontFamily` names monospace is measured and drawn in it (in `font` without it).
|
|
77
|
+
* @property {Record<string, ArrayBuffer | Uint8Array>=} families Typefaces by family name: text whose `fontFamily` names one (the first of its list given here, before a generic family) is measured and drawn in it.
|
|
78
|
+
* @property {(ArrayBuffer | Uint8Array)[]=} fallback Typefaces for the characters a typeface lacks: each such character is measured and drawn in the first of them that has it.
|
|
79
|
+
* @property {number=} pixel The world units of one pixel of a panel (0.01).
|
|
80
|
+
* @property {import('ren-core/host').Tokens=} theme Theme overrides for the primitives.
|
|
81
|
+
* @property {(boolean | 'demand')=} aria The accessibility mirror of the primitives, as ren-canv's.
|
|
82
|
+
* @property {import('ren-canv').LayoutPort=} layout Where the primitives are laid out: this thread by default, or the layout worker `remoteLayout(port)` reaches (ren-canv's).
|
|
83
|
+
* @property {import('ren-canv/backend').Picture=} image Loads an Image source into something a 2D canvas draws (an HTMLImageElement by default).
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/** Font families registered for the primitives, one per renderer. */
|
|
87
|
+
let families = 0;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The props `element` reads at creation: the Host's `construct` (compiled
|
|
91
|
+
* templates pass them too), and what a worker serving a ren-three view
|
|
92
|
+
* passes as `serve`'s `construct`. `of` (`<existing>`) cannot cross
|
|
93
|
+
* to a worker: an object made elsewhere belongs to the direct runtime.
|
|
94
|
+
*/
|
|
95
|
+
export const CONSTRUCT = new Set(['params', 'slot', 'of', 'dispose']);
|
|
96
|
+
|
|
97
|
+
/** Element props that are not three.js properties. */
|
|
98
|
+
const RESERVED = new Set([...CONSTRUCT, 'main', 'key', 'cursor']);
|
|
99
|
+
|
|
100
|
+
/** Events that bubble through ancestors; over and out go to the objects the pointer enters and leaves. */
|
|
101
|
+
const HOVER = new Set(['pointerover', 'pointerout']);
|
|
102
|
+
|
|
103
|
+
/** ren-core's record on a host node: its place among its siblings, how it attaches, its handlers. */
|
|
104
|
+
class Slot {
|
|
105
|
+
constructor() {
|
|
106
|
+
/** @type {any} */ this.parent = null;
|
|
107
|
+
/** @type {any} */ this.prev = null;
|
|
108
|
+
/** @type {any} */ this.next = null;
|
|
109
|
+
/** @type {any} */ this.first = null;
|
|
110
|
+
/** @type {any} */ this.last = null;
|
|
111
|
+
/** The parent property it is attached to, or null (added as a child object). @type {string | null} */
|
|
112
|
+
this.property = null;
|
|
113
|
+
/** The parent property's value before this node attached. @type {any} */
|
|
114
|
+
this.previous = undefined;
|
|
115
|
+
/** Event handlers by ren-core event name, or null. @type {Record<string, (event: any) => void> | null} */
|
|
116
|
+
this.handlers = null;
|
|
117
|
+
/** False (`dispose={null}`) skips this node's own dispose() when it is removed: on a geometry, material or texture element, that resource is kept; its children's still go. */
|
|
118
|
+
this.dispose = true;
|
|
119
|
+
/** True for a text or marker node: nothing in the scene. */
|
|
120
|
+
this.empty = false;
|
|
121
|
+
/** Its text, for a text node. */
|
|
122
|
+
this.text = '';
|
|
123
|
+
/** The panel of a primitive placed in a three.js object. @type {Surface | null} */
|
|
124
|
+
this.surface = null;
|
|
125
|
+
/** The properties set on an object built from `params`, applied again when new params rebuild it. @type {Map<string, any> | null} */
|
|
126
|
+
this.props = null;
|
|
127
|
+
/** The CSS cursor while the pointer is over the object or a child (null: none). @type {string | null} */
|
|
128
|
+
this.cursor = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A length a primitive sets itself, in pixels: its style's, else (unset)
|
|
134
|
+
* its shorthand prop's; null for none or a relative one.
|
|
135
|
+
* @param {number | string} style @param {number | null} short
|
|
136
|
+
*/
|
|
137
|
+
const own = (style, short) => (typeof style === 'number' ? (style === style ? style : short) : null);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The viewport a primitive's components read (the Host's `viewportOf`):
|
|
141
|
+
* its panel's size when it is placed in a panel whose primitive sets its
|
|
142
|
+
* width and height, else the root's (the canvas's). A panel sized by its
|
|
143
|
+
* content reports the root's: its size follows what it shows, so a view
|
|
144
|
+
* reading its own size could swing between two layouts. The color scheme
|
|
145
|
+
* and motion are the root's either way. A component in a panel runs before
|
|
146
|
+
* its nodes are placed: the source reports the root's until its primitive
|
|
147
|
+
* is placed, then binds to the panel.
|
|
148
|
+
*/
|
|
149
|
+
class Region {
|
|
150
|
+
/** @param {Renderer} owner @param {Item} item */
|
|
151
|
+
constructor(owner, item) {
|
|
152
|
+
this.owner = owner;
|
|
153
|
+
this.item = item;
|
|
154
|
+
/** @type {Set<(width: number, height: number, flags: number) => void>} */
|
|
155
|
+
this.viewers = new Set();
|
|
156
|
+
/** The root's viewport, as it last reported. */
|
|
157
|
+
this.root = [0, 0, 0];
|
|
158
|
+
/** The primitive placed in the panel it is bound to, whose size is the panel's when it sets one. @type {Item | null} */
|
|
159
|
+
this.top = null;
|
|
160
|
+
/** @type {(() => void) | null} */
|
|
161
|
+
this.unroot = null;
|
|
162
|
+
/** @type {(() => void) | null} */
|
|
163
|
+
this.unpanel = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {(width: number, height: number, flags: number) => void} report @returns {() => void} */
|
|
167
|
+
viewport(report) {
|
|
168
|
+
const owner = this.owner;
|
|
169
|
+
if (this.viewers.size === 0) {
|
|
170
|
+
this.unroot = owner.viewport((width, height, flags) => {
|
|
171
|
+
this.root = [width, height, flags];
|
|
172
|
+
this.tell();
|
|
173
|
+
});
|
|
174
|
+
if (!this.bind()) owner.unbound.add(this);
|
|
175
|
+
}
|
|
176
|
+
this.viewers.add(report);
|
|
177
|
+
const [w, h, f] = this.now();
|
|
178
|
+
report(w, h, f);
|
|
179
|
+
return () => {
|
|
180
|
+
if (!this.viewers.delete(report) || this.viewers.size > 0) return;
|
|
181
|
+
owner.unbound.delete(this);
|
|
182
|
+
this.unroot?.();
|
|
183
|
+
this.unpanel?.();
|
|
184
|
+
this.unroot = this.unpanel = null;
|
|
185
|
+
this.top = null;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Finds where its primitive is placed: a panel (followed), the overlay
|
|
191
|
+
* (the root's). False while it is not placed yet.
|
|
192
|
+
*/
|
|
193
|
+
bind() {
|
|
194
|
+
/** @type {any} */
|
|
195
|
+
let n = this.item;
|
|
196
|
+
while (n != null && n.__ren === undefined) n = n.parent;
|
|
197
|
+
if (n == null) return false;
|
|
198
|
+
const s = slot(n);
|
|
199
|
+
const surface = s.surface;
|
|
200
|
+
if (surface !== null && s.parent !== this.owner.scene) {
|
|
201
|
+
this.top = n;
|
|
202
|
+
// The panel's size follows its primitive's: each change of it reports again.
|
|
203
|
+
this.unpanel = surface.viewport(() => this.tell());
|
|
204
|
+
this.tell();
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** What it reports now: the panel's size when its primitive sets one, else the root's. @returns {number[]} */
|
|
210
|
+
now() {
|
|
211
|
+
const root = this.root;
|
|
212
|
+
const top = this.top;
|
|
213
|
+
if (top === null) return root;
|
|
214
|
+
const w = own(top.style.width, top.width);
|
|
215
|
+
const h = own(top.style.height, top.height);
|
|
216
|
+
return w !== null && h !== null ? [w, h, root[2]] : root;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Tells every reader. */
|
|
220
|
+
tell() {
|
|
221
|
+
if (this.viewers.size === 0) return;
|
|
222
|
+
const [w, h, f] = this.now();
|
|
223
|
+
for (const report of [...this.viewers]) report(w, h, f);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** ren-canv's item kinds of a text node and a marker (scene.js): a place in the tree, nothing in the scene. */
|
|
228
|
+
const TEXT = 8;
|
|
229
|
+
const MARKER = 9;
|
|
230
|
+
|
|
231
|
+
/** @param {any} value */
|
|
232
|
+
const slot = value => /** @type {Slot} */ (value.__ren);
|
|
233
|
+
|
|
234
|
+
/** The class name of a lowercase tag. @param {string} tag */
|
|
235
|
+
const className = tag => tag.charAt(0).toUpperCase() + tag.slice(1);
|
|
236
|
+
|
|
237
|
+
/** Reads an accessor once (element arguments are not reactive). @param {any} value */
|
|
238
|
+
const once = value => (typeof value === 'function' ? value() : value);
|
|
239
|
+
|
|
240
|
+
/** True when two params lists hold the same values. @param {any} a @param {any} b */
|
|
241
|
+
function sameParams(a, b) {
|
|
242
|
+
a = once(a);
|
|
243
|
+
b = once(b);
|
|
244
|
+
if (a === b) return true;
|
|
245
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
246
|
+
for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The slots (by target, its keys) that hold an object the view handed in,
|
|
252
|
+
* placed by reference (a scene's background Color or Texture): the view's
|
|
253
|
+
* own, so never written into, and replaced whole (null too) by what it
|
|
254
|
+
* gives next. The slot is marked, not the object: the view may hand in one
|
|
255
|
+
* three.js owns elsewhere (a material's color), which stays written in
|
|
256
|
+
* place there.
|
|
257
|
+
* @type {WeakMap<object, Set<string>>}
|
|
258
|
+
*/
|
|
259
|
+
const placed = new WeakMap();
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Sets `value` on `target[key]`: through the target's own `set`/`copy`/
|
|
263
|
+
* `setScalar` for the math objects three.js made (position, scale, a
|
|
264
|
+
* material's color; null or undefined leaves one as it is), by assignment
|
|
265
|
+
* otherwise, and always over an object the view placed.
|
|
266
|
+
* @param {any} target @param {string} key @param {any} value
|
|
267
|
+
*/
|
|
268
|
+
function assign(target, key, value) {
|
|
269
|
+
const current = target[key];
|
|
270
|
+
const own = current != null && typeof current === 'object' && placed.get(target)?.has(key) !== true;
|
|
271
|
+
if (own && typeof current.set === 'function' && !(value != null && value.isObject3D)) {
|
|
272
|
+
// Nothing to set (null or undefined): the math object keeps its value, as a Color does (set(undefined) is NaN).
|
|
273
|
+
if (value == null) return;
|
|
274
|
+
if (Array.isArray(value)) current.set(...value);
|
|
275
|
+
else if (value != null && typeof value === 'object' && typeof current.copy === 'function' && value.constructor === current.constructor) current.copy(value);
|
|
276
|
+
// A number is every component of a vector (scale={2}), but a Color's hex (color={0xff8800}).
|
|
277
|
+
else if (typeof value === 'number' && typeof current.setScalar === 'function' && current.isColor !== true) current.setScalar(value);
|
|
278
|
+
else current.set(value);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let keys = placed.get(target);
|
|
282
|
+
// A placed object taken away leaves null (a scene's background cleared), not undefined, which three.js reads as none.
|
|
283
|
+
const cleared = value == null && keys?.has(key) === true;
|
|
284
|
+
if (value != null && typeof value === 'object') {
|
|
285
|
+
if (keys === undefined) placed.set(target, (keys = new Set()));
|
|
286
|
+
keys.add(key);
|
|
287
|
+
} else keys?.delete(key);
|
|
288
|
+
target[key] = cleared ? null : value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Whether an object is drawn: it and every ancestor visible. @param {any} object */
|
|
292
|
+
function drawn(object) {
|
|
293
|
+
for (let o = object; o != null; o = o.parent) if (o.visible === false) return false;
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Frees a removed subtree's GPU resources (geometries, materials, textures),
|
|
299
|
+
* takes its cursors off the renderer's count and its objects off the
|
|
300
|
+
* hovered ones (as the DOM, a removed object hears no pointerout).
|
|
301
|
+
* @param {any} node @param {{ cursors: number, hovered: any[] }} renderer
|
|
302
|
+
*/
|
|
303
|
+
function release(node, renderer) {
|
|
304
|
+
const s = slot(node);
|
|
305
|
+
if (node instanceof Item) {
|
|
306
|
+
if (s.surface !== null) s.surface.close();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const at = renderer.hovered.indexOf(node);
|
|
310
|
+
if (at >= 0) renderer.hovered.splice(at, 1);
|
|
311
|
+
if (s.cursor !== null) {
|
|
312
|
+
s.cursor = null;
|
|
313
|
+
renderer.cursors--;
|
|
314
|
+
}
|
|
315
|
+
for (let c = s.first; c !== null; c = slot(c).next) release(c, renderer);
|
|
316
|
+
if (!s.dispose || s.empty || node.isScene) return;
|
|
317
|
+
if (typeof node.dispose === 'function') node.dispose();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* An Image source loaded as an HTMLImageElement of `doc` (the default
|
|
322
|
+
* `image`): the element once decoded, null when it fails or there is no
|
|
323
|
+
* document to make it in.
|
|
324
|
+
* @param {any} doc @param {string} source
|
|
325
|
+
* @returns {Promise<CanvasImageSource | null> | null}
|
|
326
|
+
*/
|
|
327
|
+
function element(doc, source) {
|
|
328
|
+
if (doc == null || typeof doc.createElement !== 'function') return null;
|
|
329
|
+
const img = doc.createElement('img');
|
|
330
|
+
return new Promise(resolve => {
|
|
331
|
+
img.onload = () => resolve(img);
|
|
332
|
+
img.onerror = () => resolve(null);
|
|
333
|
+
img.src = source;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A wheel's deltas in a surface's pixels: lines and pages (deltaMode 1 and 2)
|
|
340
|
+
* scroll as far as a browser scrolls them, as ren-canv's attach reads them.
|
|
341
|
+
* @param {any} surface @param {any} native @returns {[number, number]}
|
|
342
|
+
*/
|
|
343
|
+
function deltas(surface, native) {
|
|
344
|
+
const mode = native?.deltaMode ?? 0;
|
|
345
|
+
const line = surface.theme.text * surface.theme.leading;
|
|
346
|
+
const y = native?.deltaY ?? 0;
|
|
347
|
+
const x = native?.deltaX ?? 0;
|
|
348
|
+
if (mode === 1) return [y * line, x * line];
|
|
349
|
+
if (mode === 2) return [y * surface.height, x * surface.width];
|
|
350
|
+
return [y, x];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The three.js renderer: the Host `mount` renders into, and the scene,
|
|
355
|
+
* camera and frame loop it draws.
|
|
356
|
+
*/
|
|
357
|
+
export class Renderer {
|
|
358
|
+
/** @param {Kit} kit @param {Options} [options] */
|
|
359
|
+
constructor(kit, options = {}) {
|
|
360
|
+
if (kit == null || typeof kit.Scene !== 'function') throw new Error('REN three: pass the three.js namespace: $three(THREE, options)');
|
|
361
|
+
this.kit = kit;
|
|
362
|
+
this.construct = CONSTRUCT;
|
|
363
|
+
this.options = options;
|
|
364
|
+
/** The scene, and the container views render into. */
|
|
365
|
+
this.scene = new kit.Scene();
|
|
366
|
+
this.scene.__ren = new Slot();
|
|
367
|
+
this.scene.__ren.dispose = false;
|
|
368
|
+
if (options.background !== undefined) this.scene.background = new kit.Color(options.background);
|
|
369
|
+
const camera = options.camera;
|
|
370
|
+
this.camera = camera != null && camera.isCamera ? camera : new kit.PerspectiveCamera(camera?.fov ?? 50, 2, camera?.near ?? 0.1, camera?.far ?? 1000);
|
|
371
|
+
if (camera == null || !camera.isCamera) this.camera.position.set(...(camera?.position ?? [0, 0, 5]));
|
|
372
|
+
/** @type {Drawer | null} */
|
|
373
|
+
this.drawer = null;
|
|
374
|
+
/** The canvas, once open. @type {any} */
|
|
375
|
+
this.canvas = null;
|
|
376
|
+
/** The host element that open marked (`data-ren-root`), unmarked on close. @type {any} */
|
|
377
|
+
this.host = null;
|
|
378
|
+
this.width = options.width ?? 300;
|
|
379
|
+
this.height = options.height ?? 150;
|
|
380
|
+
/** Rendered frames. */
|
|
381
|
+
this.frames = 0;
|
|
382
|
+
/** True when the scene changed since the last frame. */
|
|
383
|
+
this.dirty = true;
|
|
384
|
+
/** Per-frame callbacks (`$animate`): false when one threw and nothing handled it. @type {Set<(frame: Tick) => boolean>} */
|
|
385
|
+
this.animations = new Set();
|
|
386
|
+
/** The callbacks that had a frame: a callback's first frame has delta 0, whatever time passed before it. @type {WeakSet<(frame: Tick) => boolean>} */
|
|
387
|
+
this.animated = new WeakSet();
|
|
388
|
+
/** The pending animation frame, or null. @type {any} */
|
|
389
|
+
this.pending = null;
|
|
390
|
+
this.time = 0;
|
|
391
|
+
this.opened = false;
|
|
392
|
+
this.closed = false;
|
|
393
|
+
/** Objects with handlers, for the raycast. */
|
|
394
|
+
this.listening = 0;
|
|
395
|
+
/** Objects with a `cursor`: while any, the pointer is raycast for them too. */
|
|
396
|
+
this.cursors = 0;
|
|
397
|
+
/** `<gl>`'s settings by property name, applied to the drawer. @type {any} */
|
|
398
|
+
this.gl = { settings: new Map() };
|
|
399
|
+
/** Object3D's own fields, which a rebuild from new params leaves alone. @type {Set<string> | null} */
|
|
400
|
+
this.base = null;
|
|
401
|
+
/** The objects under the pointer, nearest first (pointerover/out). @type {any[]} */
|
|
402
|
+
this.hovered = [];
|
|
403
|
+
/** Removed nodes whose resources go at the next frame unless they come back. @type {any[]} */
|
|
404
|
+
this.removed = [];
|
|
405
|
+
/** @type {(() => void) | null} */
|
|
406
|
+
this.detach = null;
|
|
407
|
+
/** @type {any} */
|
|
408
|
+
this.observer = null;
|
|
409
|
+
this.raycaster = new kit.Raycaster();
|
|
410
|
+
this.pointer = new kit.Vector2();
|
|
411
|
+
// The primitives: the overlay, the panels, and what they share.
|
|
412
|
+
/** The overlay of the primitives placed in the scene, once there is one. @type {Surface | null} */
|
|
413
|
+
this.overlay = null;
|
|
414
|
+
/** Its scene and camera, drawn over the scene. @type {any} */
|
|
415
|
+
this.overlayScene = null;
|
|
416
|
+
/** @type {any} */
|
|
417
|
+
this.overlayCamera = null;
|
|
418
|
+
/** Every surface, the overlay first. @type {Set<Surface>} */
|
|
419
|
+
this.surfaces = new Set();
|
|
420
|
+
/** The root's root-wide listeners (ren-core's `$keys`, `$scrolls` on the scene), heard on every surface; `off` by surface, which goes with it. @type {Set<{ name: string, fn: (event: any) => void, off: Map<Surface, () => void> }>} */
|
|
421
|
+
this.rooted = new Set();
|
|
422
|
+
/** The surfaces by their page's root item. @type {WeakMap<Item, Surface>} */
|
|
423
|
+
this.pages = new WeakMap();
|
|
424
|
+
/** The measure of the primitives' text. @type {import('ren-canv').Fonts | null} */
|
|
425
|
+
this.measure = null;
|
|
426
|
+
/** The font family the 2D canvas draws them in, and its monospace one (when `mono` is given). */
|
|
427
|
+
this.family = `ren-three-${++families}`;
|
|
428
|
+
this.monoFamily = options.mono == null ? null : `${this.family}-mono`;
|
|
429
|
+
/** The font family each typeface slot of the measure draws in (the named typefaces, then the fallbacks; none for slot 0). @type {(string | null)[]} */
|
|
430
|
+
this.typefaces = [null];
|
|
431
|
+
/** The faces it added to the document's fonts, deleted on close. @type {any[]} */
|
|
432
|
+
this.faces = [];
|
|
433
|
+
this.pixel = options.pixel ?? 0.01;
|
|
434
|
+
this.scale = options.scale ?? 1;
|
|
435
|
+
/** @type {import('ren-canv/backend').Picture | null} */
|
|
436
|
+
this.picture = options.image ?? (source => element(this.canvas?.ownerDocument ?? globalThis.document, source));
|
|
437
|
+
/** The surface keys, copy and select all go to; the one a press grabbed (a drag); the panel under the pointer. @type {Surface | null} */
|
|
438
|
+
this.active = null;
|
|
439
|
+
/** @type {Surface | null} */
|
|
440
|
+
this.grab = null;
|
|
441
|
+
/** @type {Surface | null} */
|
|
442
|
+
this.over = null;
|
|
443
|
+
/** True when the last press went to a surface (its click does not reach the scene). */
|
|
444
|
+
this.took = false;
|
|
445
|
+
/** The surface under a drag over the canvas (null: none). @type {Surface | null} */
|
|
446
|
+
this.dragged = null;
|
|
447
|
+
/** Who follows the viewport (ren-core's `$viewport`), and the window's preference queries while one does. @type {Set<(width: number, height: number, flags: number) => void>} */
|
|
448
|
+
this.viewers = new Set();
|
|
449
|
+
/** @type {any[] | null} */
|
|
450
|
+
this.queries = null;
|
|
451
|
+
this.tell = () => this.viewed();
|
|
452
|
+
/** Each primitive's viewport source (`viewportOf`), made on first use. @type {WeakMap<Item, Region>} */
|
|
453
|
+
this.regions = new WeakMap();
|
|
454
|
+
/** The sources followed whose primitive is not placed yet: bound as it is. @type {Set<Region>} */
|
|
455
|
+
this.unbound = new Set();
|
|
456
|
+
this.aspect();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The measure of the primitives' text, read from the `font` (and `mono`) option on first use. @returns {import('ren-canv').Fonts} */
|
|
460
|
+
fonts() {
|
|
461
|
+
if (this.measure === null) {
|
|
462
|
+
const font = this.options.font;
|
|
463
|
+
if (font == null) throw new Error('REN three: the primitives need options.font, the bytes of a typeface: $three(THREE, { font })');
|
|
464
|
+
const families = this.options.families ?? {};
|
|
465
|
+
const fallback = this.options.fallback ?? [];
|
|
466
|
+
// Each character in the typeface that has it: the monospace one for MONO, a named one for its family, a fallback for what a typeface lacks.
|
|
467
|
+
this.measure = new Faces({ font, mono: this.options.mono ?? null, families, fallback });
|
|
468
|
+
/** The bytes of each slot's typeface, after the root one. */
|
|
469
|
+
const slots = [...Object.values(families), ...fallback];
|
|
470
|
+
this.typefaces = [null, ...slots.map((_, i) => `${this.family}-${i + 1}`)];
|
|
471
|
+
// The 2D canvas draws the same faces: registered from the same bytes.
|
|
472
|
+
const doc = this.canvas?.ownerDocument ?? null;
|
|
473
|
+
const view = doc?.defaultView ?? null;
|
|
474
|
+
if (view != null && typeof view.FontFace === 'function' && doc.fonts != null) {
|
|
475
|
+
/** @param {string} family @param {ArrayBuffer | Uint8Array} source */
|
|
476
|
+
const register = (family, source) => {
|
|
477
|
+
const face = new view.FontFace(family, source instanceof Uint8Array ? source : new Uint8Array(source));
|
|
478
|
+
doc.fonts.add(face);
|
|
479
|
+
this.faces.push(face);
|
|
480
|
+
face.load().then(() => {
|
|
481
|
+
for (const surface of this.surfaces) {
|
|
482
|
+
surface.damage = true;
|
|
483
|
+
surface.full = true;
|
|
484
|
+
surface.present();
|
|
485
|
+
}
|
|
486
|
+
}, () => {});
|
|
487
|
+
};
|
|
488
|
+
register(this.family, font);
|
|
489
|
+
if (this.monoFamily !== null && this.options.mono != null) register(this.monoFamily, this.options.mono);
|
|
490
|
+
slots.forEach((source, i) => register(/** @type {string} */ (this.typefaces[i + 1]), source));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return this.measure;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The overlay, made on first use. */
|
|
497
|
+
makeOverlay() {
|
|
498
|
+
if (this.overlay === null) {
|
|
499
|
+
const kit = this.kit;
|
|
500
|
+
this.overlay = new Surface(this, false);
|
|
501
|
+
this.adopt(this.overlay);
|
|
502
|
+
this.pages.set(this.overlay.root, this.overlay);
|
|
503
|
+
this.pages.set(this.overlay.overlay, this.overlay);
|
|
504
|
+
this.overlayScene = new kit.Scene();
|
|
505
|
+
this.overlayCamera = new kit.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
506
|
+
this.overlayScene.add(this.overlay.mesh);
|
|
507
|
+
}
|
|
508
|
+
return this.overlay;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** A new surface: one of this renderer's, hearing the root-wide listeners. @param {Surface} surface */
|
|
512
|
+
adopt(surface) {
|
|
513
|
+
this.surfaces.add(surface);
|
|
514
|
+
for (const r of this.rooted) r.off.set(surface, surface.listen(surface.root, r.name, r.fn));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The surface an item belongs to (the overlay for one not placed yet). @param {Item} item */
|
|
518
|
+
page(item) {
|
|
519
|
+
/** @type {Item} */
|
|
520
|
+
let n = item;
|
|
521
|
+
while (n.parent !== null) n = n.parent;
|
|
522
|
+
return this.pages.get(n) ?? this.makeOverlay();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
get kind() {
|
|
526
|
+
return 'three';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---- Host ---------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
/** @param {string} tag @param {any} props */
|
|
532
|
+
element(tag, props) {
|
|
533
|
+
const first = tag.charCodeAt(0);
|
|
534
|
+
// A capitalized tag is a primitive: an item of the surfaces.
|
|
535
|
+
if (first >= 65 && first <= 90) return this.makeOverlay().element(tag, props);
|
|
536
|
+
let object;
|
|
537
|
+
if (tag === 'gl') {
|
|
538
|
+
// The WebGL renderer's own settings (tone mapping, exposure, the shadow
|
|
539
|
+
// map), from the view: kept, and applied to the drawer as it is made.
|
|
540
|
+
const gl = this.gl;
|
|
541
|
+
if (gl.__ren === undefined) {
|
|
542
|
+
const s = (gl.__ren = new Slot());
|
|
543
|
+
s.empty = true;
|
|
544
|
+
s.dispose = false;
|
|
545
|
+
}
|
|
546
|
+
return gl;
|
|
547
|
+
}
|
|
548
|
+
if (tag === 'existing') {
|
|
549
|
+
object = once(props?.of);
|
|
550
|
+
if (object == null || typeof object !== 'object') throw new Error('REN three: <existing> needs an object in `of`');
|
|
551
|
+
} else {
|
|
552
|
+
const code = tag.charCodeAt(0);
|
|
553
|
+
if (code < 97 || code > 122) throw new Error(`REN three: <${tag}> is not a three.js element (<mesh>, <group>, …) or a primitive (<View>, <Label>, …)`);
|
|
554
|
+
const Class = this.kit[className(tag)];
|
|
555
|
+
if (typeof Class !== 'function') throw new Error(`REN three: unknown element <${tag}>: three.js has no ${className(tag)}`);
|
|
556
|
+
const params = props == null ? undefined : once(props.params);
|
|
557
|
+
object = Array.isArray(params) ? new Class(...params) : new Class();
|
|
558
|
+
}
|
|
559
|
+
const s = (object.__ren = new Slot());
|
|
560
|
+
// New params rebuild it in place, then its properties apply again: keep them.
|
|
561
|
+
if (props != null && props.params !== undefined && tag !== 'existing') s.props = new Map();
|
|
562
|
+
if (props != null) {
|
|
563
|
+
const into = once(props.slot);
|
|
564
|
+
if (typeof into === 'string') s.property = into;
|
|
565
|
+
else if (object.isMaterial) s.property = 'material';
|
|
566
|
+
else if (object.isBufferGeometry) s.property = 'geometry';
|
|
567
|
+
else if (object.isFog) s.property = 'fog';
|
|
568
|
+
if (props.dispose === null || props.dispose === false) s.dispose = false;
|
|
569
|
+
if (tag === 'existing') s.dispose = props.dispose === true;
|
|
570
|
+
} else if (object.isMaterial) s.property = 'material';
|
|
571
|
+
else if (object.isBufferGeometry) s.property = 'geometry';
|
|
572
|
+
else if (object.isFog) s.property = 'fog';
|
|
573
|
+
return object;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** A text node: an item, drawn inside a primitive and nothing among three.js objects. @param {string} value */
|
|
577
|
+
text(value) {
|
|
578
|
+
return new Item(TEXT, value, 0);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @param {any} node @param {string} value */
|
|
582
|
+
content(node, value) {
|
|
583
|
+
if (!(node instanceof Item)) slot(node).text = value;
|
|
584
|
+
else if (node.parent !== null) this.page(node).content(node, value);
|
|
585
|
+
else node.text = value;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** @param {any} node @param {string} name @param {any} value @param {any} [previous] */
|
|
589
|
+
set(node, name, value, previous) {
|
|
590
|
+
if (node instanceof Item) {
|
|
591
|
+
this.page(node).set(node, name, value, previous);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (node === this.gl) {
|
|
595
|
+
node.settings.set(name, value);
|
|
596
|
+
if (this.drawer !== null) this.apply(this.drawer, name, value);
|
|
597
|
+
this.dirty = true;
|
|
598
|
+
this.request();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (RESERVED.has(name)) {
|
|
602
|
+
if (name === 'cursor') {
|
|
603
|
+
const s = slot(node);
|
|
604
|
+
const had = s.cursor !== null;
|
|
605
|
+
s.cursor = value == null || value === '' ? null : String(value);
|
|
606
|
+
this.cursors += (s.cursor !== null ? 1 : 0) - (had ? 1 : 0);
|
|
607
|
+
}
|
|
608
|
+
if (name === 'params' && previous !== undefined && !sameParams(previous, value)) this.rebuild(node, once(value));
|
|
609
|
+
if (name === 'main' && value && node.isCamera) {
|
|
610
|
+
this.camera = node;
|
|
611
|
+
this.aspect();
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const kept = node.__ren?.props;
|
|
616
|
+
if (kept != null) kept.set(name, value);
|
|
617
|
+
this.apply(node, name, value);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Writes a property on a three.js object: a dashed name reaches into it. @param {any} node @param {string} name @param {any} value */
|
|
621
|
+
apply(node, name, value) {
|
|
622
|
+
const dash = name.indexOf('-');
|
|
623
|
+
if (dash < 0) assign(node, name, value);
|
|
624
|
+
else {
|
|
625
|
+
const path = name.split('-');
|
|
626
|
+
let target = node;
|
|
627
|
+
for (let i = 0; i < path.length - 1; i++) target = target[path[i]];
|
|
628
|
+
assign(target, path[path.length - 1], value);
|
|
629
|
+
}
|
|
630
|
+
if (node.isMaterial && name === 'transparent') node.needsUpdate = true;
|
|
631
|
+
this.dirty = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Rebuilds an object in place from new constructor `params`: the node keeps
|
|
636
|
+
* its identity (the view, its parent and its handlers hold it). A fresh
|
|
637
|
+
* object is made and copied in: a scene object takes only what its class
|
|
638
|
+
* sets beyond Object3D's own fields (not its transform or children);
|
|
639
|
+
* anything else (a geometry, a material, a curve) takes its whole state,
|
|
640
|
+
* a geometry's old GPU buffers freed first. The properties the view set
|
|
641
|
+
* apply again after.
|
|
642
|
+
* @param {any} node @param {any} params
|
|
643
|
+
*/
|
|
644
|
+
rebuild(node, params) {
|
|
645
|
+
const Class = node.constructor;
|
|
646
|
+
const fresh = Array.isArray(params) ? new Class(...params) : new Class();
|
|
647
|
+
if (node.isObject3D) {
|
|
648
|
+
const base = (this.base ??= new Set(Object.keys(new this.kit.Object3D())));
|
|
649
|
+
for (const key of Object.keys(fresh)) if (!base.has(key) && key !== '__ren') node[key] = fresh[key];
|
|
650
|
+
} else {
|
|
651
|
+
if (typeof node.copy !== 'function') throw new Error(`REN three: a ${Class.name} cannot take new params (it has no copy); key it to make a new one`);
|
|
652
|
+
if (node.isBufferGeometry) node.dispose();
|
|
653
|
+
node.copy(fresh);
|
|
654
|
+
if (typeof fresh.dispose === 'function') fresh.dispose();
|
|
655
|
+
}
|
|
656
|
+
const kept = slot(node).props;
|
|
657
|
+
if (kept !== null) for (const [name, value] of kept) this.apply(node, name, value);
|
|
658
|
+
if (node.isCamera && typeof node.updateProjectionMatrix === 'function') node.updateProjectionMatrix();
|
|
659
|
+
if (node.isMaterial) node.needsUpdate = true;
|
|
660
|
+
this.dirty = true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** @param {any} node @param {string} name @param {(event: any) => void} handler */
|
|
664
|
+
listen(node, name, handler) {
|
|
665
|
+
if (node instanceof Item) return this.page(node).listen(node, name, handler);
|
|
666
|
+
// Root-wide keys and scrolls (`$keys`, `$scrolls`) on the scene: every surface's, the overlay's and each panel's.
|
|
667
|
+
if (node === this.scene && (name === 'keys' || name === 'scrolls')) {
|
|
668
|
+
const r = { name, fn: handler, off: new Map([...this.surfaces].map(surface => [surface, surface.listen(surface.root, name, handler)])) };
|
|
669
|
+
this.rooted.add(r);
|
|
670
|
+
return () => {
|
|
671
|
+
if (!this.rooted.delete(r)) return;
|
|
672
|
+
for (const off of r.off.values()) off();
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const s = slot(node);
|
|
676
|
+
if (s.handlers === null) s.handlers = {};
|
|
677
|
+
if (s.handlers[name] === undefined) this.listening++;
|
|
678
|
+
s.handlers[name] = handler;
|
|
679
|
+
return () => {
|
|
680
|
+
const handlers = s.handlers;
|
|
681
|
+
if (handlers !== null && handlers[name] === handler) {
|
|
682
|
+
delete handlers[name];
|
|
683
|
+
this.listening--;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** @param {any} parent @param {any} child @param {any} before */
|
|
689
|
+
insert(parent, child, before) {
|
|
690
|
+
if (parent instanceof Item) {
|
|
691
|
+
if (!(child instanceof Item)) throw new Error('REN three: a three.js object cannot go inside a primitive; place primitives in three.js objects instead');
|
|
692
|
+
this.page(parent).insert(parent, child, before);
|
|
693
|
+
if (this.unbound.size > 0) this.bind();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
// An item among three.js objects: a text or marker holds a place; a
|
|
697
|
+
// primitive joins the overlay (in the scene) or becomes a panel.
|
|
698
|
+
if (child instanceof Item && /** @type {any} */ (child).__ren === undefined) {
|
|
699
|
+
const s = (/** @type {any} */ (child).__ren = new Slot());
|
|
700
|
+
s.empty = child.kind >= TEXT;
|
|
701
|
+
}
|
|
702
|
+
const p = slot(parent);
|
|
703
|
+
const c = slot(child);
|
|
704
|
+
const moving = c.parent === parent;
|
|
705
|
+
if (c.parent !== null) this.unlink(c.parent, child, moving);
|
|
706
|
+
// Link before `before`, or last.
|
|
707
|
+
c.parent = parent;
|
|
708
|
+
c.next = before;
|
|
709
|
+
c.prev = before === null ? p.last : slot(before).prev;
|
|
710
|
+
if (c.prev === null) p.first = child;
|
|
711
|
+
else slot(c.prev).next = child;
|
|
712
|
+
if (before === null) p.last = child;
|
|
713
|
+
else slot(before).prev = child;
|
|
714
|
+
if (child instanceof Item) {
|
|
715
|
+
if (!c.empty) this.place(parent, child, moving);
|
|
716
|
+
} else if (!moving && !c.empty) {
|
|
717
|
+
if (c.property !== null) {
|
|
718
|
+
c.previous = parent[c.property];
|
|
719
|
+
parent[c.property] = child;
|
|
720
|
+
} else if (child.isObject3D && parent.isObject3D) parent.add(child);
|
|
721
|
+
}
|
|
722
|
+
this.dirty = true;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Places a primitive linked among three.js objects: in the overlay, in
|
|
727
|
+
* order among the overlay's other primitives, for the scene; in a panel
|
|
728
|
+
* of its own for any other object.
|
|
729
|
+
* @param {any} parent @param {Item} child @param {boolean} moving
|
|
730
|
+
*/
|
|
731
|
+
place(parent, child, moving) {
|
|
732
|
+
if (parent === this.scene) {
|
|
733
|
+
const layer = this.makeOverlay();
|
|
734
|
+
// Before the next primitive of the scene, in the order of the view.
|
|
735
|
+
let next = slot(child).next;
|
|
736
|
+
while (next !== null && !(next instanceof Item && !slot(next).empty)) next = slot(next).next;
|
|
737
|
+
layer.insert(layer.root, child, next);
|
|
738
|
+
if (this.unbound.size > 0) this.bind();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (moving) return;
|
|
742
|
+
const s = slot(child);
|
|
743
|
+
let surface = s.surface;
|
|
744
|
+
if (surface === null) {
|
|
745
|
+
surface = new Surface(this, true);
|
|
746
|
+
s.surface = surface;
|
|
747
|
+
this.adopt(surface);
|
|
748
|
+
this.pages.set(surface.root, surface);
|
|
749
|
+
this.pages.set(surface.overlay, surface);
|
|
750
|
+
}
|
|
751
|
+
surface.insert(surface.root, child, null);
|
|
752
|
+
parent.add(surface.mesh);
|
|
753
|
+
if (this.unbound.size > 0) this.bind();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** @param {any} parent @param {any} child */
|
|
757
|
+
remove(parent, child) {
|
|
758
|
+
if (parent instanceof Item) {
|
|
759
|
+
this.page(parent).remove(parent, child);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
this.unlink(parent, child, false);
|
|
763
|
+
this.removed.push(child);
|
|
764
|
+
this.dirty = true;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** @param {any} parent @param {any} child @param {boolean} moving */
|
|
768
|
+
unlink(parent, child, moving) {
|
|
769
|
+
const p = slot(parent);
|
|
770
|
+
const c = slot(child);
|
|
771
|
+
if (c.prev === null) p.first = c.next;
|
|
772
|
+
else slot(c.prev).next = c.next;
|
|
773
|
+
if (c.next === null) p.last = c.prev;
|
|
774
|
+
else slot(c.next).prev = c.prev;
|
|
775
|
+
c.parent = c.prev = c.next = null;
|
|
776
|
+
if (moving || c.empty) return;
|
|
777
|
+
if (child instanceof Item) {
|
|
778
|
+
if (parent === this.scene) this.overlay?.remove(this.overlay.root, child);
|
|
779
|
+
else if (c.surface !== null) {
|
|
780
|
+
c.surface.remove(c.surface.root, child);
|
|
781
|
+
parent.remove(c.surface.mesh);
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (c.property !== null) {
|
|
786
|
+
if (parent[c.property] === child) parent[c.property] = c.previous;
|
|
787
|
+
else {
|
|
788
|
+
// Not the current value: the one set over it now stands over what it stood over.
|
|
789
|
+
for (let s = p.first; s !== null; s = slot(s).next) {
|
|
790
|
+
const o = slot(s);
|
|
791
|
+
if (o.property === c.property && o.previous === child) o.previous = c.previous;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
c.previous = undefined;
|
|
795
|
+
} else if (child.isObject3D) parent.remove?.(child);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
marker() {
|
|
799
|
+
return new Item(MARKER, '', 0);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** @param {any} node */
|
|
803
|
+
parent(node) {
|
|
804
|
+
// An item placed among three.js objects has its place there.
|
|
805
|
+
if (node instanceof Item && /** @type {any} */ (node).__ren === undefined) return node.parent;
|
|
806
|
+
return slot(node).parent;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** @param {any} node */
|
|
810
|
+
next(node) {
|
|
811
|
+
if (node instanceof Item && /** @type {any} */ (node).__ren === undefined) return node.next;
|
|
812
|
+
return slot(node).next;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** @param {any} value */
|
|
816
|
+
owns(value) {
|
|
817
|
+
return value instanceof Item || (value != null && typeof value === 'object' && value.__ren instanceof Slot);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
layer() {
|
|
821
|
+
return this.scene;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The viewport (ren-core's `$viewport`, `$media`): the canvas's size in
|
|
826
|
+
* CSS pixels, and the color scheme and motion preferences of the window
|
|
827
|
+
* it is in (none without one); now, then on each resize or change.
|
|
828
|
+
* @param {(width: number, height: number, flags: number) => void} report
|
|
829
|
+
* @returns {() => void}
|
|
830
|
+
*/
|
|
831
|
+
viewport(report) {
|
|
832
|
+
const viewers = this.viewers;
|
|
833
|
+
if (viewers.size === 0) this.prefer(true);
|
|
834
|
+
viewers.add(report);
|
|
835
|
+
report(this.width, this.height, this.flags());
|
|
836
|
+
return () => {
|
|
837
|
+
if (viewers.delete(report) && viewers.size === 0) this.prefer(false);
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The viewport of the region `node` is in (ren-core's `$viewport` and
|
|
843
|
+
* `$media` in a component built there): a primitive's is its panel's
|
|
844
|
+
* once it is placed in one with a width and height of its own (see
|
|
845
|
+
* Region), else the root's; null (the root's) for a three.js object.
|
|
846
|
+
* @param {any} node
|
|
847
|
+
* @returns {Region | null}
|
|
848
|
+
*/
|
|
849
|
+
viewportOf(node) {
|
|
850
|
+
if (!(node instanceof Item)) return null;
|
|
851
|
+
let region = this.regions.get(node);
|
|
852
|
+
if (region === undefined) {
|
|
853
|
+
region = new Region(this, node);
|
|
854
|
+
this.regions.set(node, region);
|
|
855
|
+
}
|
|
856
|
+
return region;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Binds the followed sources whose primitive is placed now. */
|
|
860
|
+
bind() {
|
|
861
|
+
for (const region of this.unbound) if (region.bind()) this.unbound.delete(region);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The viewport's flags: 1 a dark color scheme, 2 reduced motion (the window's media queries). */
|
|
865
|
+
flags() {
|
|
866
|
+
const q = this.queries;
|
|
867
|
+
return q === null ? 0 : (q[0].matches ? 1 : 0) | (q[1].matches ? 2 : 0);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Follows the window's color scheme and motion preferences, or stops. @param {boolean} on */
|
|
871
|
+
prefer(on) {
|
|
872
|
+
const tell = this.tell;
|
|
873
|
+
if (!on) {
|
|
874
|
+
if (this.queries !== null) for (const q of this.queries) q.removeEventListener?.('change', tell);
|
|
875
|
+
this.queries = null;
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const win = (this.canvas ?? this.host)?.ownerDocument?.defaultView ?? globalThis;
|
|
879
|
+
if (typeof win.matchMedia !== 'function') return;
|
|
880
|
+
this.queries = [win.matchMedia('(prefers-color-scheme: dark)'), win.matchMedia('(prefers-reduced-motion: reduce)')];
|
|
881
|
+
for (const q of this.queries) q.addEventListener?.('change', tell);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Reports the viewport to its followers. */
|
|
885
|
+
viewed() {
|
|
886
|
+
if (this.viewers.size === 0) return;
|
|
887
|
+
const flags = this.flags();
|
|
888
|
+
for (const report of [...this.viewers]) report(this.width, this.height, flags);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
present() {
|
|
892
|
+
// Nodes removed and not inserted again release their resources.
|
|
893
|
+
const removed = this.removed;
|
|
894
|
+
if (removed.length > 0) {
|
|
895
|
+
this.removed = [];
|
|
896
|
+
for (const node of removed) if (slot(node).parent === null) release(node, this);
|
|
897
|
+
}
|
|
898
|
+
for (const surface of this.surfaces) {
|
|
899
|
+
if (surface.closed) {
|
|
900
|
+
// A closed panel goes, with the root-wide listeners' hold on it.
|
|
901
|
+
this.surfaces.delete(surface);
|
|
902
|
+
for (const r of this.rooted) r.off.delete(surface);
|
|
903
|
+
// Keys, a drag and the hover leave it: a key with no surface focused goes to the overlay.
|
|
904
|
+
if (this.active === surface) this.active = null;
|
|
905
|
+
if (this.grab === surface) this.grab = null;
|
|
906
|
+
if (this.over === surface) this.over = null;
|
|
907
|
+
} else surface.present();
|
|
908
|
+
}
|
|
909
|
+
if (this.dirty) this.request();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Prepares an application root: a canvas in its host element (none
|
|
914
|
+
* without a document), the drawer, pointer events and sizing.
|
|
915
|
+
* @param {any} element @param {string} id
|
|
916
|
+
*/
|
|
917
|
+
open(element, id) {
|
|
918
|
+
if (this.opened) throw new Error('REN three: a renderer serves one root; make one per root');
|
|
919
|
+
this.opened = true;
|
|
920
|
+
const doc = element?.ownerDocument ?? null;
|
|
921
|
+
if (element != null && typeof element.setAttribute === 'function') {
|
|
922
|
+
element.setAttribute('data-ren-root', id);
|
|
923
|
+
this.host = element;
|
|
924
|
+
}
|
|
925
|
+
const given = this.options.renderer;
|
|
926
|
+
let canvas = given != null && typeof given === 'object' ? (given.domElement ?? null) : null;
|
|
927
|
+
if (canvas === null && doc !== null) {
|
|
928
|
+
canvas = doc.createElement('canvas');
|
|
929
|
+
canvas.style.display = 'block';
|
|
930
|
+
}
|
|
931
|
+
this.canvas = canvas;
|
|
932
|
+
const drawer = given == null ? new this.kit.WebGLRenderer({ canvas, antialias: true }) : typeof given === 'function' ? given(canvas) : given;
|
|
933
|
+
this.drawer = drawer;
|
|
934
|
+
/** The drawer is this renderer's (made here, or by the `renderer` function for its canvas): closing loses its WebGL context. */
|
|
935
|
+
this.owned = typeof given !== 'object' || given === null;
|
|
936
|
+
for (const [name, value] of this.gl.settings) this.apply(drawer, name, value);
|
|
937
|
+
this.scale = this.options.scale ?? doc?.defaultView?.devicePixelRatio ?? 1;
|
|
938
|
+
drawer.setPixelRatio?.(this.scale);
|
|
939
|
+
if (canvas !== null && element != null && canvas.parentNode !== element) element.appendChild(canvas);
|
|
940
|
+
if (canvas !== null && typeof canvas.addEventListener === 'function') this.detach = this.attach(canvas);
|
|
941
|
+
const view = doc?.defaultView ?? null;
|
|
942
|
+
if (element != null && (this.options.width === undefined || this.options.height === undefined) && typeof view?.ResizeObserver === 'function') {
|
|
943
|
+
this.observer = new view.ResizeObserver(() => this.fit(element));
|
|
944
|
+
this.observer.observe(element);
|
|
945
|
+
this.fit(element);
|
|
946
|
+
} else this.resize(this.width, this.height);
|
|
947
|
+
return this.scene;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
close() {
|
|
951
|
+
if (this.closed) return;
|
|
952
|
+
this.closed = true;
|
|
953
|
+
this.cancel();
|
|
954
|
+
this.animations.clear();
|
|
955
|
+
if (this.detach !== null) this.detach();
|
|
956
|
+
this.detach = null;
|
|
957
|
+
if (this.observer !== null) this.observer.disconnect();
|
|
958
|
+
this.observer = null;
|
|
959
|
+
this.viewers.clear();
|
|
960
|
+
this.prefer(false);
|
|
961
|
+
for (let c = slot(this.scene).first; c !== null; ) {
|
|
962
|
+
const next = slot(c).next;
|
|
963
|
+
this.remove(this.scene, c);
|
|
964
|
+
c = next;
|
|
965
|
+
}
|
|
966
|
+
for (const node of this.removed) release(node, this);
|
|
967
|
+
this.removed = [];
|
|
968
|
+
for (const surface of this.surfaces) surface.close();
|
|
969
|
+
this.surfaces.clear();
|
|
970
|
+
for (const r of this.rooted) r.off.clear();
|
|
971
|
+
this.dragged = null;
|
|
972
|
+
this.overlay = null;
|
|
973
|
+
this.drawer?.dispose?.();
|
|
974
|
+
// dispose() keeps the WebGL context until the canvas is collected: a
|
|
975
|
+
// page that remakes roots would run out of contexts (the browser drops
|
|
976
|
+
// the oldest). Lose the one this renderer made at once.
|
|
977
|
+
if (this.owned) this.drawer?.forceContextLoss?.();
|
|
978
|
+
const canvas = this.canvas;
|
|
979
|
+
for (const face of this.faces) canvas?.ownerDocument?.fonts?.delete(face);
|
|
980
|
+
this.faces = [];
|
|
981
|
+
if (canvas !== null && canvas.parentNode != null) canvas.parentNode.removeChild(canvas);
|
|
982
|
+
this.host?.removeAttribute?.('data-ren-root');
|
|
983
|
+
this.host = null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ---- Drawing ------------------------------------------------------------
|
|
987
|
+
|
|
988
|
+
/** Keeps the camera's aspect ratio to the canvas. */
|
|
989
|
+
aspect() {
|
|
990
|
+
const camera = this.camera;
|
|
991
|
+
if (camera.isPerspectiveCamera) {
|
|
992
|
+
camera.aspect = this.width / this.height;
|
|
993
|
+
camera.updateProjectionMatrix();
|
|
994
|
+
}
|
|
995
|
+
this.dirty = true;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Sizes the canvas and the camera. @param {number} width @param {number} height */
|
|
999
|
+
resize(width, height) {
|
|
1000
|
+
const w = Math.max(1, width);
|
|
1001
|
+
const h = Math.max(1, height);
|
|
1002
|
+
const moved = w !== this.width || h !== this.height;
|
|
1003
|
+
this.width = w;
|
|
1004
|
+
this.height = h;
|
|
1005
|
+
this.drawer?.setSize(w, h);
|
|
1006
|
+
this.overlay?.resize(w, h, this.scale);
|
|
1007
|
+
this.aspect();
|
|
1008
|
+
if (moved) this.viewed();
|
|
1009
|
+
this.request();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Follows the host element's size where the options leave it open. @param {any} element */
|
|
1013
|
+
fit(element) {
|
|
1014
|
+
const width = this.options.width ?? (element.clientWidth || 300);
|
|
1015
|
+
const height = this.options.height ?? (element.clientHeight || 150);
|
|
1016
|
+
if (width !== this.width || height !== this.height || this.frames === 0) this.resize(width, height);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Asks for a frame: on the next animation frame, unless one is pending or frames are driven by hand. */
|
|
1020
|
+
request() {
|
|
1021
|
+
if (this.pending !== null || this.closed || this.drawer === null || this.options.schedule === false) return;
|
|
1022
|
+
const view = /** @type {any} */ (this.canvas?.ownerDocument?.defaultView ?? globalThis);
|
|
1023
|
+
if (typeof view.requestAnimationFrame === 'function') {
|
|
1024
|
+
// timer: feature, the scene draws once a frame.
|
|
1025
|
+
this.pending = { view, id: view.requestAnimationFrame((/** @type {number} */ time) => this.tick(time)) };
|
|
1026
|
+
} else {
|
|
1027
|
+
// timer: fallback, 60 frames a second where the host has no requestAnimationFrame.
|
|
1028
|
+
this.pending = { view: null, id: setTimeout(() => this.tick(performance.now()), 16) };
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
cancel() {
|
|
1033
|
+
const pending = this.pending;
|
|
1034
|
+
if (pending === null) return;
|
|
1035
|
+
this.pending = null;
|
|
1036
|
+
if (pending.view !== null) pending.view.cancelAnimationFrame(pending.id);
|
|
1037
|
+
else clearTimeout(pending.id);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** @param {number} time */
|
|
1041
|
+
tick(time) {
|
|
1042
|
+
this.pending = null;
|
|
1043
|
+
this.frame(time);
|
|
1044
|
+
if (this.animations.size > 0) this.request();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* One frame: runs the `$animate` callbacks, then renders if anything changed
|
|
1049
|
+
* (an animation always counts as a change).
|
|
1050
|
+
* @param {number} [time] milliseconds
|
|
1051
|
+
*/
|
|
1052
|
+
frame(time = this.time + 16) {
|
|
1053
|
+
const delta = this.frames === 0 ? 0 : (time - this.time) / 1000;
|
|
1054
|
+
this.time = time;
|
|
1055
|
+
if (this.animations.size > 0) {
|
|
1056
|
+
const state = new Tick(this, time / 1000, delta);
|
|
1057
|
+
/** @type {Tick | null} */
|
|
1058
|
+
let first = null;
|
|
1059
|
+
// A callback whose error nothing handled is dropped; the others and the render go on.
|
|
1060
|
+
for (const animate of [...this.animations]) {
|
|
1061
|
+
let frame = state;
|
|
1062
|
+
if (!this.animated.has(animate)) {
|
|
1063
|
+
this.animated.add(animate);
|
|
1064
|
+
frame = first ??= new Tick(this, time / 1000, 0);
|
|
1065
|
+
}
|
|
1066
|
+
if (!animate(frame)) this.animations.delete(animate);
|
|
1067
|
+
}
|
|
1068
|
+
this.dirty = true;
|
|
1069
|
+
}
|
|
1070
|
+
if (!this.dirty || this.drawer === null || this.closed) return false;
|
|
1071
|
+
this.dirty = false;
|
|
1072
|
+
const drawer = this.drawer;
|
|
1073
|
+
drawer.render(this.scene, this.camera);
|
|
1074
|
+
// The overlay over the scene, without clearing it.
|
|
1075
|
+
if (this.overlay !== null && this.overlay.mesh.visible) {
|
|
1076
|
+
const clears = /** @type {any} */ (drawer).autoClear;
|
|
1077
|
+
/** @type {any} */ (drawer).autoClear = false;
|
|
1078
|
+
drawer.render(this.overlayScene, this.overlayCamera);
|
|
1079
|
+
/** @type {any} */ (drawer).autoClear = clears;
|
|
1080
|
+
}
|
|
1081
|
+
this.frames++;
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ---- Pointer events -----------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* The objects at a canvas point, nearest first: three.js intersections
|
|
1089
|
+
* (`object`, `point`, `distance`, …).
|
|
1090
|
+
* @param {number} x @param {number} y CSS pixels from the canvas's top left
|
|
1091
|
+
* @returns {any[]}
|
|
1092
|
+
*/
|
|
1093
|
+
pick(x, y) {
|
|
1094
|
+
// Matrices are brought up to date here, as a render would.
|
|
1095
|
+
this.scene.updateMatrixWorld();
|
|
1096
|
+
if (this.camera.parent === null) this.camera.updateMatrixWorld();
|
|
1097
|
+
this.pointer.set((x / this.width) * 2 - 1, -(y / this.height) * 2 + 1);
|
|
1098
|
+
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
1099
|
+
// What is not drawn (the object or an ancestor not visible) takes no pointer, as it shows nothing.
|
|
1100
|
+
return this.raycaster.intersectObject(this.scene, true).filter((/** @type {any} */ hit) => drawn(hit.object));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Dispatches a pointer event at a canvas point: to the nearest object
|
|
1105
|
+
* with a handler for `type` and then its ancestors, until one stops it.
|
|
1106
|
+
* `pointermove` also sends pointerover and pointerout as the objects
|
|
1107
|
+
* under the pointer change. Returns true when a handler ran.
|
|
1108
|
+
* @param {string} type a ren-core event name (`click`, `pointerdown`, …)
|
|
1109
|
+
* @param {number} x @param {number} y
|
|
1110
|
+
* @param {any} [native] the browser event
|
|
1111
|
+
* @param {Record<string, any>} [more] fields the event also carries (a drop's `files`)
|
|
1112
|
+
*/
|
|
1113
|
+
dispatch(type, x, y, native, more) {
|
|
1114
|
+
if ((this.listening === 0 && this.cursors === 0) || this.closed) return false;
|
|
1115
|
+
const hits = this.pick(x, y);
|
|
1116
|
+
let ran = false;
|
|
1117
|
+
if (type === 'pointermove' || type === 'pointerleave') {
|
|
1118
|
+
const over = type === 'pointerleave' ? [] : this.chain(hits);
|
|
1119
|
+
// The cursor of the nearest hovered object that has one, where no primitive is under the pointer.
|
|
1120
|
+
if (this.over === null) this.shown(over.find(o => slot(o).cursor !== null) ?? null);
|
|
1121
|
+
const before = this.hovered;
|
|
1122
|
+
for (const object of before) if (!over.includes(object)) ran = this.call(object, 'pointerout', null, x, y, native) || ran;
|
|
1123
|
+
for (const object of over) if (!before.includes(object)) ran = this.call(object, 'pointerover', hits[0] ?? null, x, y, native) || ran;
|
|
1124
|
+
this.hovered = over;
|
|
1125
|
+
if (type === 'pointerleave') return ran;
|
|
1126
|
+
}
|
|
1127
|
+
if (hits.length === 0) return ran;
|
|
1128
|
+
const hit = hits[0];
|
|
1129
|
+
/** @type {any} */
|
|
1130
|
+
const event = { type, x, y, button: native?.button, buttons: native?.buttons ?? 0, deltaX: native?.deltaX ?? 0, deltaY: native?.deltaY ?? 0, shiftKey: native?.shiftKey, ctrlKey: native?.ctrlKey, altKey: native?.altKey, metaKey: native?.metaKey, object: hit.object, currentObject: null, point: hit.point, distance: hit.distance, intersection: hit, intersections: hits, native, stopped: false };
|
|
1131
|
+
event.stopPropagation = () => void (event.stopped = true);
|
|
1132
|
+
if (more !== undefined) Object.assign(event, more);
|
|
1133
|
+
for (let object = hit.object; object != null && !event.stopped; object = object.parent) {
|
|
1134
|
+
const handler = object.__ren?.handlers?.[type];
|
|
1135
|
+
if (handler === undefined) continue;
|
|
1136
|
+
event.currentObject = object;
|
|
1137
|
+
handler(event);
|
|
1138
|
+
ran = true;
|
|
1139
|
+
}
|
|
1140
|
+
return ran;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** The nearest hit and its ancestors that are part of the view. @param {any[]} hits */
|
|
1144
|
+
chain(hits) {
|
|
1145
|
+
/** @type {any[]} */
|
|
1146
|
+
const out = [];
|
|
1147
|
+
if (hits.length === 0) return out;
|
|
1148
|
+
for (let object = hits[0].object; object != null && object !== this.scene; object = object.parent) if (object.__ren !== undefined) out.push(object);
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** @param {any} object @param {string} type @param {any} hit @param {number} x @param {number} y @param {any} native */
|
|
1153
|
+
call(object, type, hit, x, y, native) {
|
|
1154
|
+
const handler = object.__ren?.handlers?.[type];
|
|
1155
|
+
if (handler === undefined) return false;
|
|
1156
|
+
handler({ type, x, y, buttons: native?.buttons ?? 0, deltaX: native?.deltaX ?? 0, deltaY: native?.deltaY ?? 0, shiftKey: native?.shiftKey === true, ctrlKey: native?.ctrlKey === true, altKey: native?.altKey === true, metaKey: native?.metaKey === true, object, currentObject: object, point: hit?.point ?? null, distance: hit?.distance ?? Infinity, intersection: hit, intersections: [], native, stopped: false, stopPropagation() {} });
|
|
1157
|
+
return true;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The surface and its pixel under a canvas point: the overlay where it
|
|
1162
|
+
* has something there, else the nearest panel the ray hits (from its
|
|
1163
|
+
* texture coordinate). Null when neither. For a `drag` of files, the
|
|
1164
|
+
* overlay also where an item under the point hears drags or drops.
|
|
1165
|
+
* @param {number} x @param {number} y @param {boolean} [drag]
|
|
1166
|
+
* @returns {[Surface, number, number] | null}
|
|
1167
|
+
*/
|
|
1168
|
+
surfaceAt(x, y, drag = false) {
|
|
1169
|
+
const layer = this.overlay;
|
|
1170
|
+
if (layer !== null && layer.mesh.visible && (layer.at(x, y) || (drag && layer.drops(x, y)))) return [layer, x, y];
|
|
1171
|
+
if (this.surfaces.size <= (layer === null ? 0 : 1)) return null;
|
|
1172
|
+
for (const hit of this.pick(x, y)) {
|
|
1173
|
+
const surface = hit.object.userData?.surface;
|
|
1174
|
+
if (surface instanceof Surface && surface.panel && hit.uv != null) return [surface, hit.uv.x * surface.width, (1 - hit.uv.y) * surface.height];
|
|
1175
|
+
// The nearest object is in front of any panel behind it.
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* A canvas point on a panel's plane, in the panel's pixels, whether the
|
|
1183
|
+
* panel is under it or not (a drag that leaves it). Null when the ray
|
|
1184
|
+
* misses the plane.
|
|
1185
|
+
* @param {Surface} surface @param {number} x @param {number} y
|
|
1186
|
+
* @returns {[number, number] | null}
|
|
1187
|
+
*/
|
|
1188
|
+
onPanel(surface, x, y) {
|
|
1189
|
+
if (!surface.panel) return [x, y];
|
|
1190
|
+
const kit = this.kit;
|
|
1191
|
+
this.pick(x, y);
|
|
1192
|
+
const mesh = surface.mesh;
|
|
1193
|
+
mesh.updateMatrixWorld();
|
|
1194
|
+
const normal = new kit.Vector3(0, 0, 1).transformDirection(mesh.matrixWorld);
|
|
1195
|
+
const plane = new kit.Plane().setFromNormalAndCoplanarPoint(normal, new kit.Vector3().setFromMatrixPosition(mesh.matrixWorld));
|
|
1196
|
+
const point = this.raycaster.ray.intersectPlane(plane, new kit.Vector3());
|
|
1197
|
+
if (point === null) return null;
|
|
1198
|
+
mesh.worldToLocal(point);
|
|
1199
|
+
return [(point.x + 0.5) * surface.width, (0.5 - point.y) * surface.height];
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Where a panel's pixel shows on the canvas, in CSS pixels.
|
|
1204
|
+
* @param {Surface} surface @param {number} px @param {number} py
|
|
1205
|
+
* @returns {[number, number]}
|
|
1206
|
+
*/
|
|
1207
|
+
project(surface, px, py) {
|
|
1208
|
+
const kit = this.kit;
|
|
1209
|
+
const mesh = surface.mesh;
|
|
1210
|
+
this.scene.updateMatrixWorld();
|
|
1211
|
+
if (this.camera.parent === null) this.camera.updateMatrixWorld();
|
|
1212
|
+
mesh.updateMatrixWorld();
|
|
1213
|
+
const point = new kit.Vector3(px / surface.width - 0.5, 0.5 - py / surface.height, 0).applyMatrix4(mesh.matrixWorld).project(this.camera);
|
|
1214
|
+
return [((point.x + 1) / 2) * this.width, ((1 - point.y) / 2) * this.height];
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* One input event at a canvas point: to the primitives first (the overlay,
|
|
1219
|
+
* then a panel under the pointer; a drag stays with the surface it
|
|
1220
|
+
* started on), then, when they take nothing, raycast into the scene.
|
|
1221
|
+
* Returns true when anything handled it.
|
|
1222
|
+
* @param {string} type `pointerdown`, `pointermove`, `pointerup`, `pointercancel`, `pointerleave`, `click`, `dblclick`, `contextmenu` or `wheel`
|
|
1223
|
+
* @param {number} x @param {number} y @param {any} [native]
|
|
1224
|
+
*/
|
|
1225
|
+
input(type, x, y, native) {
|
|
1226
|
+
if (this.closed) return false;
|
|
1227
|
+
if (type === 'pointerdown') {
|
|
1228
|
+
const at = this.surfaceAt(x, y);
|
|
1229
|
+
this.took = false;
|
|
1230
|
+
// Another button than the main one (a right press) reaches the pointer handlers only: it presses, focuses and selects nothing.
|
|
1231
|
+
const main = (native?.button ?? 0) === 0;
|
|
1232
|
+
if (main) for (const surface of this.surfaces) if (at === null || surface !== at[0]) surface.focus(null);
|
|
1233
|
+
if (at !== null) {
|
|
1234
|
+
const [surface, px, py] = at;
|
|
1235
|
+
if (main) this.active = surface;
|
|
1236
|
+
this.took = main ? surface.press(px, py, native?.detail || 1, native?.shiftKey === true, native ?? undefined) : surface.secondary(px, py, native ?? undefined);
|
|
1237
|
+
if (this.took) this.grab = surface;
|
|
1238
|
+
} else this.active = null;
|
|
1239
|
+
if (this.took) return true;
|
|
1240
|
+
} else if (type === 'pointermove') {
|
|
1241
|
+
const grab = this.grab;
|
|
1242
|
+
if (grab !== null) {
|
|
1243
|
+
const p = this.onPanel(grab, x, y);
|
|
1244
|
+
if (p !== null) grab.move(p[0], p[1], native ?? undefined);
|
|
1245
|
+
this.show(grab);
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
const at = this.surfaceAt(x, y);
|
|
1249
|
+
const over = at === null ? null : at[0];
|
|
1250
|
+
if (this.over !== null && this.over !== over) this.over.leave();
|
|
1251
|
+
this.over = over;
|
|
1252
|
+
if (at !== null) at[0].move(at[1], at[2], native ?? undefined);
|
|
1253
|
+
this.show(over);
|
|
1254
|
+
// The overlay on top takes the pointer: the scene behind it is left, not moved over. A panel
|
|
1255
|
+
// is in the scene: its mesh and ancestors keep hearing the move (their over and out).
|
|
1256
|
+
if (at !== null && at[0] === this.overlay) return this.dispatch('pointerleave', x, y, native);
|
|
1257
|
+
} else if (type === 'pointerup' || type === 'pointercancel') {
|
|
1258
|
+
// A cancel (the browser took the pointer back) ends the press with pointercancel, no pointerup.
|
|
1259
|
+
const grab = this.grab;
|
|
1260
|
+
this.grab = null;
|
|
1261
|
+
if (grab !== null) {
|
|
1262
|
+
const p = this.onPanel(grab, x, y) ?? [0, 0];
|
|
1263
|
+
if (type === 'pointerup') grab.release(p[0], p[1], native ?? undefined);
|
|
1264
|
+
else grab.cancel(p[0], p[1], native ?? undefined);
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
} else if (type === 'pointerleave') {
|
|
1268
|
+
if (this.over !== null) this.over.leave();
|
|
1269
|
+
this.over = null;
|
|
1270
|
+
} else if (type === 'wheel') {
|
|
1271
|
+
const at = this.surfaceAt(x, y);
|
|
1272
|
+
// Over the overlay, the wheel is its own, used or not: it never reaches the scene behind it.
|
|
1273
|
+
// Over a panel, one the panel does not use goes on to the scene, the panel's ancestors among it.
|
|
1274
|
+
if (at !== null) {
|
|
1275
|
+
const [dy, dx] = deltas(at[0], native);
|
|
1276
|
+
if (at[0] === this.overlay) return at[0].wheel(at[1], at[2], dy, native ?? undefined, dx);
|
|
1277
|
+
if (at[0].wheel(at[1], at[2], dy, native ?? undefined, dx)) return true;
|
|
1278
|
+
}
|
|
1279
|
+
} else if (this.took && (type === 'click' || type === 'dblclick' || type === 'contextmenu')) return true;
|
|
1280
|
+
return this.dispatch(type, x, y, native);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* A drag over the canvas at a point, carrying `files` files of these MIME
|
|
1285
|
+
* `types` (all a drag tells before its drop): to the surface under it, as
|
|
1286
|
+
* a press (the overlay by point, a panel by the raycast's texture
|
|
1287
|
+
* coordinate), whose items hear it as ren-canv's `dragOver` sends it.
|
|
1288
|
+
* Moving to another surface leaves the first, then enters the second.
|
|
1289
|
+
* Over no surface, the scene's objects under it hear `dragover` through
|
|
1290
|
+
* the raycast. Returns whether a surface or an object took it (the
|
|
1291
|
+
* browser's default is prevented only then).
|
|
1292
|
+
* @param {number} x @param {number} y @param {number} [files] @param {readonly string[]} [types] @param {any} [native]
|
|
1293
|
+
*/
|
|
1294
|
+
dragOver(x, y, files = 0, types = [], native) {
|
|
1295
|
+
if (this.closed) return false;
|
|
1296
|
+
const at = this.surfaceAt(x, y, true);
|
|
1297
|
+
const surface = at === null ? null : at[0];
|
|
1298
|
+
const last = this.dragged;
|
|
1299
|
+
this.dragged = surface;
|
|
1300
|
+
if (last !== null && last !== surface && !last.closed) last.dragLeave();
|
|
1301
|
+
if (at !== null) {
|
|
1302
|
+
at[0].dragOver(at[1], at[2], files, types);
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
return this.dispatch('dragover', x, y, native, { files, types });
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** The drag left the canvas (or was cancelled): the surface under it leaves. */
|
|
1309
|
+
dragLeave() {
|
|
1310
|
+
const last = this.dragged;
|
|
1311
|
+
this.dragged = null;
|
|
1312
|
+
if (last !== null && !last.closed) last.dragLeave();
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Files dropped at a point: on the surface under it (its items hear
|
|
1317
|
+
* `drop` with the files and the point in its pixels), else on the scene's
|
|
1318
|
+
* objects through the raycast (`onDrop` with `files`). A drop ends the
|
|
1319
|
+
* drag without a leave where it lands. Returns whether a surface or an
|
|
1320
|
+
* object took it.
|
|
1321
|
+
* @param {number} x @param {number} y @param {readonly any[]} files @param {any} [native]
|
|
1322
|
+
*/
|
|
1323
|
+
dropFiles(x, y, files, native) {
|
|
1324
|
+
if (this.closed) return false;
|
|
1325
|
+
const at = this.surfaceAt(x, y, true);
|
|
1326
|
+
const last = this.dragged;
|
|
1327
|
+
this.dragged = null;
|
|
1328
|
+
if (last !== null && (at === null || last !== at[0]) && !last.closed) last.dragLeave();
|
|
1329
|
+
if (at !== null) {
|
|
1330
|
+
at[0].dropFiles(at[1], at[2], files);
|
|
1331
|
+
return true;
|
|
1332
|
+
}
|
|
1333
|
+
return this.dispatch('drop', x, y, native, { files });
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** Shows a three.js object's cursor on the canvas (null: the page's own). @param {any} object */
|
|
1337
|
+
shown(object) {
|
|
1338
|
+
const style = this.canvas?.style;
|
|
1339
|
+
if (style == null) return;
|
|
1340
|
+
const cursor = object === null ? '' : /** @type {string} */ (slot(object).cursor);
|
|
1341
|
+
if (style.cursor !== cursor) style.cursor = cursor;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Takes a new theme for the primitives at once: the overlay and every
|
|
1346
|
+
* panel resolve, lay out and repaint; the scene and all state stay.
|
|
1347
|
+
* @param {import('ren-core/host').Tokens} tokens
|
|
1348
|
+
*/
|
|
1349
|
+
setTheme(tokens) {
|
|
1350
|
+
this.options = { ...this.options, theme: tokens };
|
|
1351
|
+
for (const surface of this.surfaces) surface.setTheme(tokens);
|
|
1352
|
+
this.dirty = true;
|
|
1353
|
+
this.request();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Shows the cursor of the primitives under the pointer on the canvas (their
|
|
1358
|
+
* style's `cursor`); leaves the page's own cursor over the scene.
|
|
1359
|
+
* @param {Surface | null} surface
|
|
1360
|
+
*/
|
|
1361
|
+
show(surface) {
|
|
1362
|
+
const style = this.canvas?.style;
|
|
1363
|
+
if (style == null) return;
|
|
1364
|
+
const cursor = surface === null ? '' : surface.cursor === 'default' ? '' : surface.cursor;
|
|
1365
|
+
if (style.cursor !== cursor) style.cursor = cursor;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Routes a canvas element's pointer, wheel, key and copy events: to the
|
|
1370
|
+
* primitives and the scene (`input`), keys and copy to the surface last
|
|
1371
|
+
* pressed (keys with none to the overlay or the first panel, for the
|
|
1372
|
+
* root's `$keys`; a chord reaches the handlers, as on ren-canv's attach).
|
|
1373
|
+
* Returns the function that stops.
|
|
1374
|
+
* @param {any} canvas
|
|
1375
|
+
*/
|
|
1376
|
+
attach(canvas) {
|
|
1377
|
+
if (canvas.tabIndex === undefined || canvas.tabIndex < 0) canvas.tabIndex = 0;
|
|
1378
|
+
const types = ['click', 'dblclick', 'contextmenu', 'pointerdown', 'pointerup', 'pointermove', 'pointerleave', 'wheel'];
|
|
1379
|
+
/**
|
|
1380
|
+
* Where the pointer was last (a cancel reports 0, 0), whether its press
|
|
1381
|
+
* was taken, and the pans and zoom the primitives under it leave to the
|
|
1382
|
+
* page (their `touchAction`'s bits, -1 where none sets one, or no
|
|
1383
|
+
* surface is there): a touch keeps the page from what they forbid.
|
|
1384
|
+
*/
|
|
1385
|
+
let lastX = 0;
|
|
1386
|
+
let lastY = 0;
|
|
1387
|
+
let taken = false;
|
|
1388
|
+
let allow = -1;
|
|
1389
|
+
/** A touch whose pan its first move decides (some pans allowed, not all), and where it started. */
|
|
1390
|
+
let pending = false;
|
|
1391
|
+
let startX = 0;
|
|
1392
|
+
let startY = 0;
|
|
1393
|
+
/** @param {any} e */
|
|
1394
|
+
const listener = e => {
|
|
1395
|
+
if (e.type === 'pointerdown' && typeof canvas.focus === 'function') canvas.focus();
|
|
1396
|
+
// A drag keeps the pointer past the canvas's edge, as a native drag does (ren-canv's attach does the same).
|
|
1397
|
+
if (e.type === 'pointerdown' && (e.button ?? 0) === 0) {
|
|
1398
|
+
try {
|
|
1399
|
+
canvas.setPointerCapture?.(e.pointerId);
|
|
1400
|
+
} catch {
|
|
1401
|
+
// A synthetic pointer may not be capturable.
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
// A canvas the page's CSS resized: the pointer in the pixels it is drawn in.
|
|
1405
|
+
const sx = canvas.clientWidth > 0 ? this.width / canvas.clientWidth : 1;
|
|
1406
|
+
const sy = canvas.clientHeight > 0 ? this.height / canvas.clientHeight : 1;
|
|
1407
|
+
const x = e.offsetX * sx;
|
|
1408
|
+
const y = e.offsetY * sy;
|
|
1409
|
+
// The browser's context menu stays away where a surface's item shows the app's own (prevent="contextmenu").
|
|
1410
|
+
if (e.type === 'contextmenu') {
|
|
1411
|
+
const at = this.surfaceAt(x, y);
|
|
1412
|
+
if (at !== null && at[0].cancels('contextmenu', at[1], at[2])) e.preventDefault();
|
|
1413
|
+
}
|
|
1414
|
+
if (e.type === 'pointerdown' || e.type === 'pointermove') {
|
|
1415
|
+
lastX = x;
|
|
1416
|
+
lastY = y;
|
|
1417
|
+
}
|
|
1418
|
+
if (e.type === 'pointerdown') {
|
|
1419
|
+
const at = e.pointerType === 'touch' ? this.surfaceAt(x, y) : null;
|
|
1420
|
+
allow = at === null ? -1 : at[0].touchAction(at[1], at[2]);
|
|
1421
|
+
}
|
|
1422
|
+
const handled = this.input(e.type, x, y, e);
|
|
1423
|
+
if (e.type === 'pointerdown') taken = handled;
|
|
1424
|
+
if (handled && (e.type === 'wheel' || (e.type === 'pointerdown' && this.took))) e.preventDefault();
|
|
1425
|
+
};
|
|
1426
|
+
/**
|
|
1427
|
+
* A touch keeps the page from the pans and zoom the primitives under it
|
|
1428
|
+
* forbid (`touchAction`, as ren-canv's attach follows it): its
|
|
1429
|
+
* touchstart is cancelled when they allow nothing; its first move, when
|
|
1430
|
+
* it goes along an axis they forbid; a second finger's, when they forbid
|
|
1431
|
+
* a pinch zoom. Where none sets one, a touch that pressed a primitive or
|
|
1432
|
+
* an object with a handler is the app's: the page does not pan under it.
|
|
1433
|
+
* @param {any} e
|
|
1434
|
+
*/
|
|
1435
|
+
const touch = e => {
|
|
1436
|
+
pending = false;
|
|
1437
|
+
if (e.cancelable === false) return;
|
|
1438
|
+
const fingers = e.touches?.length ?? 1;
|
|
1439
|
+
if (allow < 0) {
|
|
1440
|
+
if (taken) e.preventDefault();
|
|
1441
|
+
} else if (fingers > 1) {
|
|
1442
|
+
if ((allow & 4) === 0) e.preventDefault();
|
|
1443
|
+
} else if (allow === 0) e.preventDefault();
|
|
1444
|
+
else if ((allow & 3) !== 3) {
|
|
1445
|
+
pending = true;
|
|
1446
|
+
startX = e.touches?.[0]?.clientX ?? 0;
|
|
1447
|
+
startY = e.touches?.[0]?.clientY ?? 0;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
/** A touch's first move decides its pan: along an axis its primitives forbid, the page stays. @param {any} e */
|
|
1451
|
+
const moved = e => {
|
|
1452
|
+
if (allow < 0 || e.cancelable === false) return;
|
|
1453
|
+
if ((e.touches?.length ?? 1) > 1) {
|
|
1454
|
+
if ((allow & 4) === 0) e.preventDefault();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (!pending) return;
|
|
1458
|
+
const dx = Math.abs((e.touches?.[0]?.clientX ?? startX) - startX);
|
|
1459
|
+
const dy = Math.abs((e.touches?.[0]?.clientY ?? startY) - startY);
|
|
1460
|
+
if (dx === 0 && dy === 0) return;
|
|
1461
|
+
pending = false;
|
|
1462
|
+
if ((allow & (dx > dy ? 1 : 2)) === 0) e.preventDefault();
|
|
1463
|
+
};
|
|
1464
|
+
/** The browser took the pointer back: the press ends where the pointer last was, with pointercancel. @param {any} e */
|
|
1465
|
+
const cancel = e => {
|
|
1466
|
+
taken = false;
|
|
1467
|
+
pending = false;
|
|
1468
|
+
this.input('pointercancel', lastX, lastY, e);
|
|
1469
|
+
};
|
|
1470
|
+
/** @param {any} e */
|
|
1471
|
+
const key = e => {
|
|
1472
|
+
const surface = this.active ?? this.overlay ?? this.surfaces.values().next().value ?? null;
|
|
1473
|
+
if (surface === null) return;
|
|
1474
|
+
if (surface.key(e.key, e.shiftKey, e)) e.preventDefault();
|
|
1475
|
+
else if (surface === this.active && (e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'a' || e.key === 'A') && surface.selectAll()) e.preventDefault();
|
|
1476
|
+
};
|
|
1477
|
+
/** @param {any} e */
|
|
1478
|
+
const copy = e => {
|
|
1479
|
+
const text = this.active?.copied() ?? '';
|
|
1480
|
+
if (text === '' || e.clipboardData == null) return;
|
|
1481
|
+
e.clipboardData.setData('text/plain', text);
|
|
1482
|
+
e.preventDefault();
|
|
1483
|
+
};
|
|
1484
|
+
/**
|
|
1485
|
+
* Files dragged over the canvas and dropped: to the surface or object
|
|
1486
|
+
* under them; the default (the browser opening a dropped file) is
|
|
1487
|
+
* prevented only where one takes them.
|
|
1488
|
+
* @param {any} e
|
|
1489
|
+
*/
|
|
1490
|
+
const drag = e => {
|
|
1491
|
+
if (e.type === 'dragleave') return this.dragLeave();
|
|
1492
|
+
const x = e.offsetX * (canvas.clientWidth > 0 ? this.width / canvas.clientWidth : 1);
|
|
1493
|
+
const y = e.offsetY * (canvas.clientHeight > 0 ? this.height / canvas.clientHeight : 1);
|
|
1494
|
+
if (e.type === 'drop') {
|
|
1495
|
+
const files = e.dataTransfer?.files;
|
|
1496
|
+
if (this.dropFiles(x, y, files == null ? [] : [...files], e)) e.preventDefault();
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const items = e.dataTransfer?.items;
|
|
1500
|
+
/** @type {string[]} */
|
|
1501
|
+
const kinds = [];
|
|
1502
|
+
if (items != null) for (const item of items) if (item.kind === 'file') kinds.push(item.type ?? '');
|
|
1503
|
+
if (this.dragOver(x, y, kinds.length, kinds, e)) e.preventDefault();
|
|
1504
|
+
};
|
|
1505
|
+
const drags = ['dragenter', 'dragover', 'dragleave', 'drop'];
|
|
1506
|
+
for (const type of types) canvas.addEventListener(type, listener, type === 'wheel' ? { passive: false } : undefined);
|
|
1507
|
+
for (const type of drags) canvas.addEventListener(type, drag);
|
|
1508
|
+
canvas.addEventListener('keydown', key);
|
|
1509
|
+
canvas.addEventListener('copy', copy);
|
|
1510
|
+
canvas.addEventListener('pointercancel', cancel);
|
|
1511
|
+
canvas.addEventListener('touchstart', touch, { passive: false });
|
|
1512
|
+
canvas.addEventListener('touchmove', moved, { passive: false });
|
|
1513
|
+
return () => {
|
|
1514
|
+
for (const type of types) canvas.removeEventListener(type, listener);
|
|
1515
|
+
for (const type of drags) canvas.removeEventListener(type, drag);
|
|
1516
|
+
canvas.removeEventListener('keydown', key);
|
|
1517
|
+
canvas.removeEventListener('copy', copy);
|
|
1518
|
+
canvas.removeEventListener('pointercancel', cancel);
|
|
1519
|
+
canvas.removeEventListener('touchstart', touch);
|
|
1520
|
+
canvas.removeEventListener('touchmove', moved);
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* What a `$animate` callback receives each frame. In a worker there is no
|
|
1527
|
+
* renderer, scene or camera: they are null there, and the callback changes
|
|
1528
|
+
* signals instead.
|
|
1529
|
+
*/
|
|
1530
|
+
export class Tick {
|
|
1531
|
+
/** @param {Renderer | null} renderer @param {number} time seconds @param {number} delta seconds since the last frame */
|
|
1532
|
+
constructor(renderer, time, delta) {
|
|
1533
|
+
this.renderer = renderer;
|
|
1534
|
+
this.scene = renderer === null ? null : renderer.scene;
|
|
1535
|
+
this.camera = renderer === null ? null : renderer.camera;
|
|
1536
|
+
this.time = time;
|
|
1537
|
+
this.delta = delta;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* A three.js renderer for `mount` or an application root's `renderer`.
|
|
1543
|
+
* @param {Kit} kit the three.js namespace
|
|
1544
|
+
* @param {Options} [options]
|
|
1545
|
+
*/
|
|
1546
|
+
export function $three(kit, options) {
|
|
1547
|
+
return new Renderer(kit, options);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Renders `view()` into a renderer's scene; returns the dispose function.
|
|
1552
|
+
* @param {() => any} view
|
|
1553
|
+
* @param {Renderer} target
|
|
1554
|
+
* @param {import('ren-core/host').Options} [options]
|
|
1555
|
+
* @returns {() => void}
|
|
1556
|
+
*/
|
|
1557
|
+
export function render(view, target, options) {
|
|
1558
|
+
if (!target.opened) target.open(null, 'main');
|
|
1559
|
+
const dispose = mount(target, view, target.scene, options);
|
|
1560
|
+
return () => {
|
|
1561
|
+
dispose();
|
|
1562
|
+
target.present();
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Runs `fn` before every frame while the calling component lives (its
|
|
1568
|
+
* owner's cleanup stops it). Rendered by ren-three, it runs in the
|
|
1569
|
+
* renderer's frame loop, which draws every animation frame while any runs.
|
|
1570
|
+
* Anywhere else (a worker serving the view), it runs on that thread's own
|
|
1571
|
+
* frames (`requestAnimationFrame` where the thread has it, 16 ms timers
|
|
1572
|
+
* otherwise), with a Tick whose renderer, scene and camera are null.
|
|
1573
|
+
* @param {(frame: Tick) => void} fn
|
|
1574
|
+
*/
|
|
1575
|
+
export function $animate(fn) {
|
|
1576
|
+
const H = /** @type {any} */ ($surface());
|
|
1577
|
+
const R = $active();
|
|
1578
|
+
const owner = $owner(R);
|
|
1579
|
+
/**
|
|
1580
|
+
* Runs `fn` for a frame. What it throws goes to its owner's boundary (a
|
|
1581
|
+
* Recover around it), as a handler's error does; false when nothing
|
|
1582
|
+
* handled it (reported), and the callback is dropped.
|
|
1583
|
+
* @param {Tick} frame
|
|
1584
|
+
*/
|
|
1585
|
+
const run = frame => {
|
|
1586
|
+
try {
|
|
1587
|
+
fn(frame);
|
|
1588
|
+
return true;
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
const left = $deliver(R, owner, error);
|
|
1591
|
+
// What the boundary shows now, it shows this frame.
|
|
1592
|
+
if (!(/** @type {any} */ (R).closed)) $flush(R);
|
|
1593
|
+
if (left === null) return true;
|
|
1594
|
+
const g = /** @type {any} */ (globalThis);
|
|
1595
|
+
if (typeof g.reportError === 'function') g.reportError(left);
|
|
1596
|
+
else console.error(left);
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
// A ren-three renderer (by shape, so a worker bundle carries no Renderer).
|
|
1601
|
+
if (H.kind === 'three' && H.animations instanceof Set) {
|
|
1602
|
+
H.animations.add(run);
|
|
1603
|
+
if (owner !== null) $cleanup(owner, () => H.animations.delete(run));
|
|
1604
|
+
H.request();
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const g = /** @type {any} */ (globalThis);
|
|
1608
|
+
const raf = typeof g.requestAnimationFrame === 'function';
|
|
1609
|
+
let last = -1;
|
|
1610
|
+
let id = /** @type {any} */ (null);
|
|
1611
|
+
let live = true;
|
|
1612
|
+
/** @param {number} now */
|
|
1613
|
+
const tick = now => {
|
|
1614
|
+
if (!live) return;
|
|
1615
|
+
const time = now ?? performance.now();
|
|
1616
|
+
const delta = last < 0 ? 0 : (time - last) / 1000;
|
|
1617
|
+
last = time;
|
|
1618
|
+
try {
|
|
1619
|
+
if (!run(new Tick(null, time / 1000, delta))) live = false;
|
|
1620
|
+
} finally {
|
|
1621
|
+
// timer: feature, the animation's frame loop; fallback, 60 frames a second where the host has no requestAnimationFrame.
|
|
1622
|
+
if (live) id = raf ? g.requestAnimationFrame(tick) : setTimeout(() => tick(performance.now()), 16);
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
// timer: feature, as above (fallback without requestAnimationFrame).
|
|
1626
|
+
id = raf ? g.requestAnimationFrame(tick) : setTimeout(() => tick(performance.now()), 16);
|
|
1627
|
+
if (owner !== null)
|
|
1628
|
+
$cleanup(owner, () => {
|
|
1629
|
+
live = false;
|
|
1630
|
+
if (raf) g.cancelAnimationFrame(id);
|
|
1631
|
+
else clearTimeout(id);
|
|
1632
|
+
});
|
|
1633
|
+
}
|