lecodes-gl-lite 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +27 -0
  2. package/package.json +33 -0
  3. package/src/assets/decoders/README.md +13 -0
  4. package/src/assets/decoders/basis_transcoder.js +19 -0
  5. package/src/assets/decoders/basis_transcoder.wasm +0 -0
  6. package/src/assets/decoders/draco_decoder.wasm +0 -0
  7. package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
  8. package/src/assets/neutral_ibl128.ktx +0 -0
  9. package/src/core/animBridge.ts +51 -0
  10. package/src/core/boneNames.ts +22 -0
  11. package/src/core/frame.ts +230 -0
  12. package/src/core/glbStructure.ts +100 -0
  13. package/src/core/headlessBridge.ts +92 -0
  14. package/src/core/mapView.ts +195 -0
  15. package/src/core/math3d.ts +275 -0
  16. package/src/core/particleBridge.ts +154 -0
  17. package/src/core/physicsBridge.ts +305 -0
  18. package/src/core/recording.ts +59 -0
  19. package/src/core/sceneCore.ts +833 -0
  20. package/src/core/serialize.ts +144 -0
  21. package/src/gen/unsupported.gen.ts +212 -0
  22. package/src/index.ts +5 -0
  23. package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
  24. package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
  25. package/src/vendor/creator-anim/js/index.ts +656 -0
  26. package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
  27. package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
  28. package/src/vendor/creator-nav/js/index.ts +400 -0
  29. package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
  30. package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
  31. package/src/vendor/creator-particles/js/index.ts +137 -0
  32. package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
  33. package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
  34. package/src/vendor/creator-physics/js/index.ts +515 -0
  35. package/src/vite-env.d.ts +1 -0
  36. package/src/webgl/batching.ts +453 -0
  37. package/src/webgl/createViewerLite.ts +1627 -0
  38. package/src/webgl/customMaterial.ts +228 -0
  39. package/src/webgl/decoders/basis.ts +123 -0
  40. package/src/webgl/decoders/draco.ts +110 -0
  41. package/src/webgl/decoders/index.ts +68 -0
  42. package/src/webgl/decoders/worker.ts +45 -0
  43. package/src/webgl/editor.ts +1297 -0
  44. package/src/webgl/glb.ts +593 -0
  45. package/src/webgl/ibl.ts +137 -0
  46. package/src/webgl/shaders.ts +779 -0
@@ -0,0 +1,1297 @@
1
+ // Engine-side EDITOR layer for the lite viewport (HOST API — not part of the bridge ABI or the
2
+ // parity surface; apps never see it). Everything the 3D scene editor displays on top of a user
3
+ // scene lives here: the ground grid, the selection outline, viewport picking, and the transform
4
+ // gizmo. It is deliberately NOT built from scene entities: the gizmo/grid are engine-owned OGL
5
+ // nodes rendered in dedicated passes and the gizmo handles are hit-test by pure ray math — no
6
+ // overlay Scene, no colliders, no touch-system involvement. (The previous design put the gizmo in
7
+ // an SDK overlay scene, which forced clicks to be routed into two scenes and per-handle collider
8
+ // bookkeeping; this replaces all of that.)
9
+ //
10
+ // The scene editor connects through `_creator._editorConnect(handlers)` (createViewerLite) and
11
+ // gets back { setGrid, setSelection, setGizmoMode, setGizmoSpace, setColliders, setGizmos }. Picking reports raw entity
12
+ // ids — the harness bundle owns the entity→node-name policy (locked nodes, GLB part drill-down).
13
+ //
14
+ // Pointer contract: listeners sit on the CANVAS, which is a child of the host root the touch
15
+ // system listens on — so a gizmo grab can stopPropagation() and the orbit track never starts,
16
+ // while plain clicks fall through to the touch system untouched (a sub-5px pointerdown→up on the
17
+ // canvas additionally resolves to a pick). Drag math follows the reference three.js gizmo
18
+ // (letary-creator createGizmo.ts): a camera-facing plane through the drag axis, deltas projected
19
+ // onto the axis; rotation reads a signed angle in the ring plane with Ctrl snapping to 15°.
20
+ // Scale always manipulates LOCAL axes (a world-axis scale isn't expressible in a TRS local
21
+ // matrix), matching the old gizmo's ruling.
22
+
23
+ import {
24
+ Box, Camera, Cylinder, Geometry, GLTFSkin, Mesh, Program, Raycast, RenderTarget, Renderer,
25
+ Torus, Transform, Triangle, type OGLRenderingContext,
26
+ } from 'ogl'
27
+ import { identity, invertTRS, multiply, transformPoint, type Mat4Like } from '../core/math3d'
28
+ import { convexHullTriangles } from '../vendor/creator-physics/js/index'
29
+ import { depthFragment, depthVertex } from './shaders'
30
+ import type { SceneCore } from '../core/sceneCore'
31
+ import { viewScale } from 'lecodes-web-shared/src/ui/viewScale'
32
+ import { Color } from 'lecodes-sdk/color'
33
+
34
+ export type GizmoMode = 'translate' | 'rotate' | 'scale'
35
+ export type GizmoSpace = 'global' | 'local'
36
+
37
+ /** An entity's LOCAL transform, decomposed — what a gizmo drag reports back to the host. */
38
+ export type EditorPose = {
39
+ position: [number, number, number]
40
+ quaternion: [number, number, number, number]
41
+ scale: [number, number, number]
42
+ }
43
+
44
+ export type EditorHandlers = {
45
+ /** A plain viewport click resolved to the topmost entity under it (0 = empty space). */
46
+ onPick?(entityId: number): void
47
+ /** A gizmo drag ended; `pose` is the entity's final local transform. Not emitted on Escape. */
48
+ onTransformEnd?(entityId: number, pose: EditorPose, mode: GizmoMode): void
49
+ }
50
+
51
+ /** One collider to draw (the scene editor derives these from the document's `Shape` aspects —
52
+ * in edit mode aspects never attach, so there is no physics body to read back). Dimensions follow
53
+ * the SDK's Shape contract: explicit primitives are WORLD units centred on the node (the node's
54
+ * scale is ignored), `auto` is the node's own geometry AABB × world scale (a non-Mesh node gets
55
+ * the SDK's 0.5 default box), `mesh` is the node subtree's triangles × world scale. */
56
+ export type EditorCollider = {
57
+ entityId: number
58
+ /** The shape's centre relative to the node (Shape `origin`) — world units in the node's rotated,
59
+ * UNSCALED frame, like the dimensions. Omitted = centred on the node, except `auto`, which
60
+ * falls back to the centre of the geometry it measured. */
61
+ origin?: [number, number, number]
62
+ shape:
63
+ | { kind: 'auto' }
64
+ | { kind: 'box', half: [number, number, number] }
65
+ | { kind: 'sphere', radius: number }
66
+ | { kind: 'cylinder' | 'capsule', halfHeight: number, radius: number }
67
+ | { kind: 'mesh', convex: boolean }
68
+ }
69
+
70
+ /** One color batch of LINES (flat `[x,y,z, x,y,z]` per segment) — what the SDK's `Gizmos.*`
71
+ * calls produce; the scene editor pushes them through `setGizmos`. With `entityId` the batch is
72
+ * ANCHORED: segments are in that entity's local frame (scale stripped), the lines follow the
73
+ * entity every frame (through a gizmo drag), pick like the entity's own geometry (screen-space
74
+ * tolerance, on top of the picture) and draw in the selection accent while it is selected. */
75
+ export type EditorGizmoBatch = { color: string, alpha: number, segments: number[], entityId?: number }
76
+
77
+ /** The camera PREVIEW inset: render the scene once more from an entity's world pose (−Z = view)
78
+ * with its own lens, into a rect of the canvas (CSS px, origin top-left). `hide` lists
79
+ * editor-only entities (node markers) to leave out — the inset is the game's frame. */
80
+ export type EditorPreview = {
81
+ entityId: number
82
+ fov: number
83
+ near: number
84
+ far: number
85
+ /** CSS px, relative to the canvas, origin top-left. */
86
+ rect: [number, number, number, number]
87
+ hide?: number[]
88
+ }
89
+
90
+ /** What the engine hands the layer (closure state of createViewerLite). */
91
+ export type EditorContext = {
92
+ gl: OGLRenderingContext & WebGL2RenderingContext
93
+ renderer: Renderer
94
+ canvas: HTMLCanvasElement
95
+ camera: Camera
96
+ core: SceneCore
97
+ sceneRoots: Map<number, Transform>
98
+ drawablesOf(entityId: number): Mesh[]
99
+ collectMeshes(root: Transform): Mesh[]
100
+ /** The engine's LINES companion geometry for a triangle geometry (the wireframe mode's cache). */
101
+ wireGeometryFor(geometry: Geometry): Geometry
102
+ getViewRay(sceneId: number, x: number, y: number): { origin: number[], dir: number[] }
103
+ /** Draw a scene root with a camera through the engine's batched scene pass (batching.ts). */
104
+ renderScene(root: Transform, camera: Camera, force?: boolean): void
105
+ }
106
+
107
+ // Screen-constant gizmo size: world scale = camera distance × this × vertical fov in degrees
108
+ // (the reference gizmo's formula — ~15% of the view height at fov 60).
109
+ const GIZMO_SCALE = 0.0025
110
+ const ARROW_LEN = 1.0 // axis handle tip, in gizmo units (× the screen-constant scale)
111
+ const RING_RADIUS = 0.85
112
+ const CENTER_RADIUS = 0.13 // uniform-scale cube pick sphere
113
+ const HIT_THRESHOLD = 0.1 // handle grab distance, in gizmo units
114
+ const CLICK_SLOP_PX = 5 // pointerdown→up under this = a pick, over = an orbit
115
+ const GIZMO_PICK_PX = 8 // anchored gizmo lines pick within this many logical px
116
+ const ROTATE_SNAP = (15 * Math.PI) / 180
117
+
118
+ const AXIS_COLORS: [number, number, number][] = [
119
+ [0.93, 0.16, 0.12], // X — #ee2a1f-ish (reference gizmo palette)
120
+ [0.42, 0.90, 0.16], // Y
121
+ [0.16, 0.35, 1.0], // Z
122
+ ]
123
+ const CENTER_COLOR: [number, number, number] = [0.85, 0.85, 0.88]
124
+ const highlight = (c: [number, number, number]): [number, number, number] =>
125
+ [c[0] * 0.5 + 0.5, c[1] * 0.5 + 0.5, c[2] * 0.5 + 0.5]
126
+
127
+ const OUTLINE_COLOR = [0.941, 0.396, 0.243] // the editor accent (#f0653e)
128
+ const COLLIDER_COLOR = [0.36, 0.92, 0.48] // physics green (Unity's collider gizmo convention)
129
+ const COLLIDER_ALPHA = 0.85
130
+
131
+ // ---- small vector helpers (plain number[] in/out — matches getViewRay) --------------------------
132
+
133
+ type V3 = number[]
134
+ const sub3 = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
135
+ const add3 = (a: V3, b: V3): V3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
136
+ const scale3 = (a: V3, s: number): V3 => [a[0] * s, a[1] * s, a[2] * s]
137
+ const dot3 = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
138
+ const cross3 = (a: V3, b: V3): V3 => [
139
+ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0],
140
+ ]
141
+ const len3 = (a: V3): number => Math.hypot(a[0], a[1], a[2])
142
+ const norm3 = (a: V3): V3 => { const l = len3(a) || 1; return [a[0] / l, a[1] / l, a[2] / l] }
143
+
144
+ /** Ray→plane parameter (plane through `point` with `normal`); null when parallel or behind. */
145
+ const intersectRayPlane = (origin: V3, dir: V3, point: V3, normal: V3): V3 | null => {
146
+ const denom = dot3(dir, normal)
147
+ if (Math.abs(denom) < 1e-6) return null
148
+ const t = dot3(sub3(point, origin), normal) / denom
149
+ if (t <= 0) return null
150
+ return add3(origin, scale3(dir, t))
151
+ }
152
+
153
+ /** Distance between a ray and an axis segment [0, segLen] from `axisOrigin` along unit `axisDir`,
154
+ * plus the segment parameter of the closest approach (the same math the old gizmo unit-tested). */
155
+ const rayToAxisSegment = (
156
+ rayO: V3, rayD: V3, axisOrigin: V3, axisDir: V3, segLen: number,
157
+ ): { dist: number, t: number } => {
158
+ const w = sub3(rayO, axisOrigin)
159
+ const b = dot3(axisDir, rayD)
160
+ const d = dot3(axisDir, w)
161
+ const e = dot3(rayD, w)
162
+ const denom = 1 - b * b
163
+ const raw = Math.abs(denom) < 1e-8 ? d : (d - b * e) / denom
164
+ const t = Math.max(0, Math.min(segLen, raw))
165
+ const p = add3(axisOrigin, scale3(axisDir, t))
166
+ const s = Math.max(0, dot3(sub3(p, rayO), rayD))
167
+ return { dist: len3(sub3(p, add3(rayO, scale3(rayD, s)))), t }
168
+ }
169
+
170
+ /** The camera-facing drag plane containing `axisDir` through `origin` (reference createPlane). */
171
+ const dragPlaneNormal = (origin: V3, axisDir: V3, camPos: V3): V3 => {
172
+ const right = cross3(norm3(sub3(origin, camPos)), axisDir)
173
+ const normal = cross3(axisDir, right)
174
+ if (dot3(normal, normal) < 0.0001) return norm3(sub3(origin, camPos))
175
+ return norm3(normal)
176
+ }
177
+
178
+ /** Column-major rotation matrix around a unit world axis (Rodrigues). */
179
+ const axisAngleMatrix = (axis: V3, angle: number): Float32Array => {
180
+ const [x, y, z] = axis
181
+ const c = Math.cos(angle), s = Math.sin(angle), t = 1 - c
182
+ const m = new Float32Array(16)
183
+ m[0] = t * x * x + c; m[1] = t * x * y + s * z; m[2] = t * x * z - s * y
184
+ m[4] = t * x * y - s * z; m[5] = t * y * y + c; m[6] = t * y * z + s * x
185
+ m[8] = t * x * z + s * y; m[9] = t * y * z - s * x; m[10] = t * z * z + c
186
+ m[15] = 1
187
+ return m
188
+ }
189
+
190
+ /** Decompose a TRS local matrix into the pose the host persists. */
191
+ const decomposePose = (m: ArrayLike<number>): EditorPose => {
192
+ const sx = Math.hypot(m[0], m[1], m[2]) || 1
193
+ const sy = Math.hypot(m[4], m[5], m[6]) || 1
194
+ const sz = Math.hypot(m[8], m[9], m[10]) || 1
195
+ const r00 = m[0] / sx, r10 = m[1] / sx, r20 = m[2] / sx
196
+ const r01 = m[4] / sy, r11 = m[5] / sy, r21 = m[6] / sy
197
+ const r02 = m[8] / sz, r12 = m[9] / sz, r22 = m[10] / sz
198
+ const trace = r00 + r11 + r22
199
+ let x: number, y: number, z: number, w: number
200
+ if (trace > 0) {
201
+ const s = 0.5 / Math.sqrt(trace + 1)
202
+ w = 0.25 / s; x = (r21 - r12) * s; y = (r02 - r20) * s; z = (r10 - r01) * s
203
+ } else if (r00 > r11 && r00 > r22) {
204
+ const s = 2 * Math.sqrt(1 + r00 - r11 - r22)
205
+ w = (r21 - r12) / s; x = 0.25 * s; y = (r01 + r10) / s; z = (r02 + r20) / s
206
+ } else if (r11 > r22) {
207
+ const s = 2 * Math.sqrt(1 + r11 - r00 - r22)
208
+ w = (r02 - r20) / s; x = (r01 + r10) / s; y = 0.25 * s; z = (r12 + r21) / s
209
+ } else {
210
+ const s = 2 * Math.sqrt(1 + r22 - r00 - r11)
211
+ w = (r10 - r01) / s; x = (r02 + r20) / s; y = (r12 + r21) / s; z = 0.25 * s
212
+ }
213
+ return { position: [m[12], m[13], m[14]], quaternion: [x, y, z, w], scale: [sx, sy, sz] }
214
+ }
215
+
216
+ // ---- shaders ------------------------------------------------------------------------------------
217
+
218
+ /** Flat-color program with a cheap view-space "headlight" term (matcap-ish depth cue). */
219
+ const HANDLE_VERTEX = /* glsl */ `#version 300 es
220
+ precision highp float;
221
+ in vec3 position;
222
+ in vec3 normal;
223
+ uniform mat4 modelViewMatrix;
224
+ uniform mat4 projectionMatrix;
225
+ uniform mat3 normalMatrix;
226
+ out vec3 vN;
227
+ void main() {
228
+ vN = normalize(normalMatrix * normal);
229
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
230
+ }
231
+ `
232
+ const HANDLE_FRAGMENT = /* glsl */ `#version 300 es
233
+ precision highp float;
234
+ in vec3 vN;
235
+ uniform vec3 uColor;
236
+ out vec4 fragColor;
237
+ void main() { fragColor = vec4(uColor * (0.72 + 0.28 * abs(vN.z)), 1.0); }
238
+ `
239
+
240
+ // Rotation rings are UNLIT and Blender-like: the hemisphere facing away from the camera is
241
+ // discarded per fragment (a full circle reads as a sphere and hides what's behind it). During a
242
+ // drag the grabbed ring goes full-circle again so the sweep stays readable.
243
+ const RING_VERTEX = /* glsl */ `#version 300 es
244
+ precision highp float;
245
+ in vec3 position;
246
+ uniform mat4 modelMatrix;
247
+ uniform mat4 modelViewMatrix;
248
+ uniform mat4 projectionMatrix;
249
+ out vec3 vWorld;
250
+ void main() {
251
+ vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
252
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
253
+ }
254
+ `
255
+ const RING_FRAGMENT = /* glsl */ `#version 300 es
256
+ precision highp float;
257
+ in vec3 vWorld;
258
+ uniform vec3 uColor;
259
+ uniform vec3 uOrigin;
260
+ uniform vec3 uEye;
261
+ uniform float uFull;
262
+ out vec4 fragColor;
263
+ void main() {
264
+ if (uFull < 0.5 && dot(vWorld - uOrigin, uOrigin - uEye) > 0.0) discard;
265
+ fragColor = vec4(uColor, 1.0);
266
+ }
267
+ `
268
+
269
+ const LINE_VERTEX = /* glsl */ `#version 300 es
270
+ precision highp float;
271
+ in vec3 position;
272
+ uniform mat4 modelViewMatrix;
273
+ uniform mat4 projectionMatrix;
274
+ void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
275
+ `
276
+ // Premultiplied output — the canvas is premultipliedAlpha and the grid blends ONE / 1-SRC_ALPHA
277
+ // (same convention as the outline composite below).
278
+ const LINE_FRAGMENT = /* glsl */ `#version 300 es
279
+ precision highp float;
280
+ uniform vec3 uColor;
281
+ uniform float uAlpha;
282
+ out vec4 fragColor;
283
+ void main() { fragColor = vec4(uColor * uAlpha, uAlpha); }
284
+ `
285
+
286
+ // Fullscreen composite: tint the pixels near (but outside) the selection mask — a 2px outline.
287
+ const OUTLINE_VERTEX = /* glsl */ `#version 300 es
288
+ in vec2 position;
289
+ in vec2 uv;
290
+ out vec2 vUv;
291
+ void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
292
+ `
293
+ const OUTLINE_FRAGMENT = /* glsl */ `#version 300 es
294
+ precision highp float;
295
+ in vec2 vUv;
296
+ uniform sampler2D tMask;
297
+ uniform vec2 uTexel;
298
+ uniform vec3 uColor;
299
+ out vec4 fragColor;
300
+ void main() {
301
+ float center = texture(tMask, vUv).r;
302
+ float edge = 0.0;
303
+ for (int x = -2; x <= 2; x++) {
304
+ for (int y = -2; y <= 2; y++) {
305
+ edge = max(edge, texture(tMask, vUv + vec2(float(x), float(y)) * uTexel).r);
306
+ }
307
+ }
308
+ float a = clamp(edge - center, 0.0, 1.0);
309
+ if (a < 0.02) discard;
310
+ fragColor = vec4(uColor * a, a);
311
+ }
312
+ `
313
+
314
+ // -------------------------------------------------------------------------------------------------
315
+
316
+ export const createEditorLayer = (ctx: EditorContext) => {
317
+ const { gl, renderer, canvas, camera, core } = ctx
318
+
319
+ let handlers: EditorHandlers = {}
320
+ let gridVisible = false
321
+ let selectedEntity: number | null = null
322
+ let mode: GizmoMode | null = null
323
+ let space: GizmoSpace = 'global'
324
+
325
+ // ---- grid -------------------------------------------------------------------------------------
326
+ // The same 20×20 unit grid + brighter center cross the harness bundle used to add to the user's
327
+ // scene — now engine-owned, so the Filament preview (and shipped apps) never see it. Rendered
328
+ // right after the main pass with depth testing on, so scene content occludes it correctly.
329
+ //
330
+ // The lines are a translucent TINT OF THE BACKDROP, not a fixed slate: 10% black over a light
331
+ // skybox, 10% white over a dark one (the center cross doubles that so the origin still reads).
332
+ // One fixed color can't work for both — it either drew a hard grey cage over a light sky or
333
+ // vanished into a dark one. `setGridBackground` (below) repaints it as the skybox changes.
334
+ //
335
+ // Depth WRITING stays on (the default): the overlay pass right after this one clears the depth
336
+ // buffer, and a glDepthMask(false) left behind would silently no-op that clear.
337
+
338
+ const GRID_ALPHA = 0.1
339
+ const AXIS_ALPHA = 0.22
340
+ const gridPrograms: Program[] = []
341
+ const gridRoot = new Transform()
342
+ {
343
+ const HALF = 10
344
+ const lines: number[] = []
345
+ for (let i = -HALF; i <= HALF; i++) {
346
+ if (i === 0) continue
347
+ lines.push(i, 0, -HALF, i, 0, HALF, -HALF, 0, i, HALF, 0, i)
348
+ }
349
+ const lineMesh = (positions: number[], alpha: number): Mesh => {
350
+ const program = new Program(gl, {
351
+ vertex: LINE_VERTEX,
352
+ fragment: LINE_FRAGMENT,
353
+ uniforms: { uColor: { value: [1, 1, 1] }, uAlpha: { value: alpha } } as any,
354
+ transparent: true,
355
+ })
356
+ program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
357
+ gridPrograms.push(program)
358
+ const geometry = new Geometry(gl, { position: { size: 3, data: Float32Array.from(positions) } } as any)
359
+ const mesh = new Mesh(gl, { geometry, program, mode: gl.LINES })
360
+ mesh.frustumCulled = false
361
+ return mesh
362
+ }
363
+ gridRoot.addChild(lineMesh(lines, GRID_ALPHA))
364
+ // center X/Z axes, slightly raised to win the depth tie against the grid plane
365
+ gridRoot.addChild(lineMesh(
366
+ [-HALF, 0.001, 0, HALF, 0.001, 0, 0, 0.001, -HALF, 0, 0.001, HALF],
367
+ AXIS_ALPHA,
368
+ ))
369
+ }
370
+
371
+ /** Ink the grid against the backdrop it sits on: `bg` is the DISPLAY-space color the frame was
372
+ * cleared with (the tonemapped skybox). Perceptual luminance picks black-on-light /
373
+ * white-on-dark; the alphas above never change, so the grid stays a whisper either way. */
374
+ let gridInk = -1
375
+ const setGridBackground = (bg: readonly number[]): void => {
376
+ const lum = 0.2126 * (bg[0] ?? 0) + 0.7152 * (bg[1] ?? 0) + 0.0722 * (bg[2] ?? 0)
377
+ const ink = lum > 0.5 ? 0 : 1
378
+ if (ink === gridInk) return
379
+ gridInk = ink
380
+ for (const program of gridPrograms) program.uniforms.uColor.value = [ink, ink, ink]
381
+ }
382
+
383
+ // ---- gizmo meshes -------------------------------------------------------------------------------
384
+ // One root, three mode groups. Handles are tagged with `_axis` (0/1/2, 3 = uniform center) so a
385
+ // shared program can recolor per draw and a drag can solo its own handle.
386
+
387
+ const handleProgram = new Program(gl, {
388
+ vertex: HANDLE_VERTEX,
389
+ fragment: HANDLE_FRAGMENT,
390
+ uniforms: { uColor: { value: [1, 1, 1] } } as any,
391
+ })
392
+ const ringProgram = new Program(gl, {
393
+ vertex: RING_VERTEX,
394
+ fragment: RING_FRAGMENT,
395
+ uniforms: {
396
+ uColor: { value: [1, 1, 1] },
397
+ uOrigin: { value: [0, 0, 0] },
398
+ uEye: { value: [0, 0, 0] },
399
+ uFull: { value: 0 },
400
+ } as any,
401
+ })
402
+
403
+ let hoverAxis: number | null = null
404
+ let dragging: {
405
+ axis: number
406
+ startLocal: Float32Array
407
+ onMove: (e: PointerEvent) => void
408
+ cleanup: () => void
409
+ } | null = null
410
+
411
+ const gizmoRoot = new Transform()
412
+ gizmoRoot.matrixAutoUpdate = false
413
+
414
+ const tagHandle = (mesh: Mesh, axis: number, program: Program = handleProgram): Mesh => {
415
+ ;(mesh as any)._axis = axis
416
+ mesh.frustumCulled = false
417
+ mesh.onBeforeRender(() => {
418
+ const active = dragging ? dragging.axis : hoverAxis
419
+ const base = axis === 3 ? CENTER_COLOR : AXIS_COLORS[axis]
420
+ ;(program.uniforms as any).uColor.value = active === axis ? highlight(base) : base
421
+ })
422
+ return mesh
423
+ }
424
+
425
+ /** A group whose +Y is rotated onto the given axis (0=X, 1=Y, 2=Z). */
426
+ const axisGroup = (parent: Transform, axis: number): Transform => {
427
+ const g = new Transform()
428
+ if (axis === 0) g.rotation.z = -Math.PI / 2
429
+ else if (axis === 2) g.rotation.x = Math.PI / 2
430
+ g.setParent(parent)
431
+ return g
432
+ }
433
+
434
+ const translateGroup = new Transform()
435
+ const rotateGroup = new Transform()
436
+ const scaleGroup = new Transform()
437
+ for (const g of [translateGroup, rotateGroup, scaleGroup]) g.setParent(gizmoRoot)
438
+ {
439
+ // shafts run from the ORIGIN out (the reference gizmo look — no gap at the pivot)
440
+ const shaftGeo = new Cylinder(gl, { radiusTop: 0.014, radiusBottom: 0.014, height: 0.78, radialSegments: 6 })
441
+ const coneGeo = new Cylinder(gl, { radiusTop: 0.001, radiusBottom: 0.065, height: 0.26, radialSegments: 12 })
442
+ const cubeGeo = new Box(gl, { width: 0.1, height: 0.1, depth: 0.1 })
443
+ const centerGeo = new Box(gl, { width: 0.14, height: 0.14, depth: 0.14 })
444
+ const ringGeo = new Torus(gl, { radius: RING_RADIUS, tube: 0.02, radialSegments: 8, tubularSegments: 64 })
445
+
446
+ for (let axis = 0; axis < 3; axis++) {
447
+ const tg = axisGroup(translateGroup, axis)
448
+ const shaft = tagHandle(new Mesh(gl, { geometry: shaftGeo, program: handleProgram }), axis)
449
+ shaft.position.y = 0.39
450
+ const cone = tagHandle(new Mesh(gl, { geometry: coneGeo, program: handleProgram }), axis)
451
+ cone.position.y = 0.87 // tip lands at ARROW_LEN
452
+ tg.addChild(shaft); tg.addChild(cone)
453
+
454
+ const sg = axisGroup(scaleGroup, axis)
455
+ const sShaft = tagHandle(new Mesh(gl, { geometry: shaftGeo, program: handleProgram }), axis)
456
+ sShaft.position.y = 0.39
457
+ const cube = tagHandle(new Mesh(gl, { geometry: cubeGeo, program: handleProgram }), axis)
458
+ cube.position.y = 0.83 // flush with the shaft end
459
+ sg.addChild(sShaft); sg.addChild(cube)
460
+
461
+ // torus lies in the XY plane (ring around Z) — rotate its axis onto the target one
462
+ const ring = tagHandle(new Mesh(gl, { geometry: ringGeo, program: ringProgram }), axis, ringProgram)
463
+ if (axis === 0) ring.rotation.y = Math.PI / 2
464
+ else if (axis === 1) ring.rotation.x = Math.PI / 2
465
+ rotateGroup.addChild(ring)
466
+ }
467
+ scaleGroup.addChild(tagHandle(new Mesh(gl, { geometry: centerGeo, program: handleProgram }), 3))
468
+ }
469
+
470
+ // ---- gizmo state --------------------------------------------------------------------------------
471
+
472
+ type GizmoState = { origin: V3, axes: V3[], scale: number, camPos: V3, sceneId: number }
473
+
474
+ /** Live world-space frame of the gizmo (null when it shouldn't show). */
475
+ const gizmoState = (): GizmoState | null => {
476
+ if (selectedEntity === null || mode === null) return null
477
+ if (!core.entities.has(selectedEntity)) return null
478
+ const sceneId = core.activeSceneId()
479
+ const scene = core.scenes.get(sceneId)
480
+ if (!scene || scene.camera === undefined) return null
481
+ const w = core.worldMatrixF(selectedEntity)
482
+ const origin: V3 = [w[12], w[13], w[14]]
483
+ // Scale is always manipulated on LOCAL axes (world-axis scale isn't a TRS-expressible op).
484
+ const useLocal = space === 'local' || mode === 'scale'
485
+ const axes: V3[] = useLocal
486
+ ? [0, 1, 2].map((i) => norm3([w[i * 4], w[i * 4 + 1], w[i * 4 + 2]]))
487
+ : [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
488
+ const cw = core.worldMatrixF(scene.camera)
489
+ const camPos: V3 = [cw[12], cw[13], cw[14]]
490
+ const dist = len3(sub3(origin, camPos)) || 1
491
+ return { origin, axes, scale: dist * GIZMO_SCALE * core.projectionOf(sceneId).fov, camPos, sceneId }
492
+ }
493
+
494
+ /** Sync the gizmo root + handle visibility to the live state; false = nothing to draw. */
495
+ const syncGizmo = (): boolean => {
496
+ const g = gizmoState()
497
+ if (!g) return false
498
+ const m = gizmoRoot.matrix as unknown as number[]
499
+ for (let i = 0; i < 3; i++) {
500
+ m[i] = g.axes[0][i] * g.scale
501
+ m[4 + i] = g.axes[1][i] * g.scale
502
+ m[8 + i] = g.axes[2][i] * g.scale
503
+ m[12 + i] = g.origin[i]
504
+ }
505
+ m[3] = 0; m[7] = 0; m[11] = 0; m[15] = 1
506
+ gizmoRoot.worldMatrixNeedsUpdate = true
507
+ translateGroup.visible = mode === 'translate'
508
+ rotateGroup.visible = mode === 'rotate'
509
+ scaleGroup.visible = mode === 'scale'
510
+ const ru = ringProgram.uniforms as any
511
+ ru.uOrigin.value = g.origin
512
+ ru.uEye.value = g.camPos
513
+ ru.uFull.value = dragging && mode === 'rotate' ? 1 : 0
514
+ // a drag solos its own handle (the reference gizmo's behavior)
515
+ for (const group of [translateGroup, rotateGroup, scaleGroup]) {
516
+ group.traverse((node: any) => {
517
+ if (node._axis !== undefined) node.visible = dragging === null || node._axis === dragging.axis
518
+ })
519
+ }
520
+ return true
521
+ }
522
+
523
+ /** Which handle a viewport ray grabs (0/1/2 axis, 3 = uniform center), or null. */
524
+ const hitTestGizmo = (x: number, y: number): number | null => {
525
+ const g = gizmoState()
526
+ if (!g) return null
527
+ const { origin, dir } = ctx.getViewRay(g.sceneId, x, y)
528
+ const threshold = HIT_THRESHOLD * g.scale
529
+ if (mode === 'rotate') {
530
+ // only the near hemisphere is drawn (Blender-style), so only it may be grabbed
531
+ const viewDir = norm3(sub3(g.origin, g.camPos))
532
+ let best: number | null = null
533
+ let bestT = Infinity
534
+ for (let axis = 0; axis < 3; axis++) {
535
+ const p = intersectRayPlane(origin, dir, g.origin, g.axes[axis])
536
+ if (!p) continue
537
+ if (dot3(sub3(p, g.origin), viewDir) > 0) continue
538
+ const err = Math.abs(len3(sub3(p, g.origin)) - RING_RADIUS * g.scale)
539
+ const t = len3(sub3(p, origin))
540
+ if (err < threshold && t < bestT) { best = axis; bestT = t }
541
+ }
542
+ return best
543
+ }
544
+ // uniform-scale center cube: a sphere around the origin
545
+ if (mode === 'scale') {
546
+ const { dist } = rayToAxisSegment(origin, dir, g.origin, norm3(dir), 0)
547
+ if (dist < CENTER_RADIUS * g.scale) return 3
548
+ }
549
+ let best: number | null = null
550
+ let bestDist = threshold
551
+ for (let axis = 0; axis < 3; axis++) {
552
+ const { dist } = rayToAxisSegment(origin, dir, g.origin, g.axes[axis], (ARROW_LEN + 0.05) * g.scale)
553
+ if (dist <= bestDist) { bestDist = dist; best = axis }
554
+ }
555
+ return best
556
+ }
557
+
558
+ // ---- gizmo drags --------------------------------------------------------------------------------
559
+
560
+ const toCanvasXY = (e: { clientX: number, clientY: number }): [number, number] => {
561
+ const rect = canvas.getBoundingClientRect()
562
+ const s = viewScale(canvas)
563
+ return [(e.clientX - rect.left) / s, (e.clientY - rect.top) / s]
564
+ }
565
+
566
+ const beginDrag = (axis: number, e: PointerEvent): boolean => {
567
+ const g = gizmoState()
568
+ if (!g || selectedEntity === null) return false
569
+ const id = selectedEntity
570
+ const entity = core.ent(id)
571
+ const startLocal = Float32Array.from(entity.m as ArrayLike<number>)
572
+ const parentWorld = entity.parent !== 0 ? core.worldMatrixF(entity.parent) : identity()
573
+ const invParent = invertTRS(Float32Array.from(parentWorld))
574
+ const startWorld = multiply(new Float32Array(16), parentWorld, startLocal)
575
+ const [x0] = toCanvasXY(e)
576
+
577
+ let onMove: ((e: PointerEvent) => void) | null = null
578
+
579
+ if (axis === 3) {
580
+ // uniform scale: pointer-x based factor
581
+ onMove = (ev) => {
582
+ const [x] = toCanvasXY(ev)
583
+ const f = Math.max(0.01, 1 + (x - x0) / 150)
584
+ const m = Float32Array.from(startLocal)
585
+ for (let c = 0; c < 3; c++) for (let i = 0; i < 3; i++) m[c * 4 + i] *= f
586
+ core.methods.setMatrix(id, m)
587
+ }
588
+ } else if (mode === 'rotate') {
589
+ const axisW = g.axes[axis]
590
+ const { origin, dir } = ctx.getViewRay(g.sceneId, ...toCanvasXY(e))
591
+ const p0 = intersectRayPlane(origin, dir, g.origin, axisW)
592
+ if (!p0) return false
593
+ const v0 = sub3(p0, g.origin)
594
+ onMove = (ev) => {
595
+ const ray = ctx.getViewRay(g.sceneId, ...toCanvasXY(ev))
596
+ const p = intersectRayPlane(ray.origin, ray.dir, g.origin, axisW)
597
+ if (!p) return
598
+ const v = sub3(p, g.origin)
599
+ let angle = Math.atan2(dot3(cross3(v0, v), axisW), dot3(v0, v))
600
+ if (ev.ctrlKey) angle = Math.round(angle / ROTATE_SNAP) * ROTATE_SNAP
601
+ // world' = T(o) · R · T(−o) · world → local' = parent⁻¹ · world'
602
+ const R = axisAngleMatrix(axisW, angle)
603
+ const ro: number[] = [0, 0, 0]
604
+ transformPoint(ro, R, g.origin[0], g.origin[1], g.origin[2], 1)
605
+ R[12] = g.origin[0] - ro[0]; R[13] = g.origin[1] - ro[1]; R[14] = g.origin[2] - ro[2]
606
+ const world = multiply(new Float32Array(16), R, startWorld)
607
+ const local = entity.parent !== 0 ? multiply(new Float32Array(16), invParent, world) : world
608
+ core.methods.setMatrix(id, local as Float32Array)
609
+ }
610
+ } else {
611
+ const axisW = g.axes[axis]
612
+ const planeN = dragPlaneNormal(g.origin, axisW, g.camPos)
613
+ const { origin, dir } = ctx.getViewRay(g.sceneId, ...toCanvasXY(e))
614
+ const p0 = intersectRayPlane(origin, dir, g.origin, planeN)
615
+ if (!p0) return false
616
+ if (mode === 'translate') {
617
+ const startWorldPos: V3 = [startWorld[12], startWorld[13], startWorld[14]]
618
+ onMove = (ev) => {
619
+ const ray = ctx.getViewRay(g.sceneId, ...toCanvasXY(ev))
620
+ const p = intersectRayPlane(ray.origin, ray.dir, g.origin, planeN)
621
+ if (!p) return
622
+ const delta = dot3(sub3(p, p0), axisW)
623
+ const world = add3(startWorldPos, scale3(axisW, delta))
624
+ const local: number[] = [0, 0, 0]
625
+ if (entity.parent !== 0) transformPoint(local, invParent, world[0], world[1], world[2], 1)
626
+ else { local[0] = world[0]; local[1] = world[1]; local[2] = world[2] }
627
+ core.methods.setPosition(id, local[0], local[1], local[2])
628
+ }
629
+ } else {
630
+ // per-axis scale: drag distance along the LOCAL axis, one gizmo length = factor +1
631
+ onMove = (ev) => {
632
+ const ray = ctx.getViewRay(g.sceneId, ...toCanvasXY(ev))
633
+ const p = intersectRayPlane(ray.origin, ray.dir, g.origin, planeN)
634
+ if (!p) return
635
+ const f = Math.max(0.01, 1 + dot3(sub3(p, p0), axisW) / (ARROW_LEN * g.scale))
636
+ const m = Float32Array.from(startLocal)
637
+ for (let i = 0; i < 3; i++) m[axis * 4 + i] *= f
638
+ core.methods.setMatrix(id, m)
639
+ }
640
+ }
641
+ }
642
+ if (!onMove) return false
643
+
644
+ const win = canvas.ownerDocument.defaultView ?? window
645
+ const pointerId = e.pointerId
646
+ const handleMove = (ev: PointerEvent) => { if (ev.pointerId === pointerId) dragging?.onMove(ev) }
647
+ const finish = (commit: boolean) => {
648
+ if (!dragging) return
649
+ const drag = dragging
650
+ dragging = null
651
+ drag.cleanup()
652
+ if (commit) {
653
+ handlers.onTransformEnd?.(id, decomposePose(core.ent(id).m as ArrayLike<number>), mode!)
654
+ } else {
655
+ core.methods.setMatrix(id, drag.startLocal) // Escape → restore
656
+ }
657
+ }
658
+ const handleUp = (ev: PointerEvent) => { if (ev.pointerId === pointerId) finish(true) }
659
+ const handleCancel = () => finish(false)
660
+ const handleKey = (ev: KeyboardEvent) => { if (ev.code === 'Escape') finish(false) }
661
+ win.addEventListener('pointermove', handleMove)
662
+ win.addEventListener('pointerup', handleUp)
663
+ win.addEventListener('pointercancel', handleCancel)
664
+ win.addEventListener('keydown', handleKey)
665
+ dragging = {
666
+ axis,
667
+ startLocal,
668
+ onMove,
669
+ cleanup: () => {
670
+ win.removeEventListener('pointermove', handleMove)
671
+ win.removeEventListener('pointerup', handleUp)
672
+ win.removeEventListener('pointercancel', handleCancel)
673
+ win.removeEventListener('keydown', handleKey)
674
+ },
675
+ }
676
+ return true
677
+ }
678
+
679
+ // ---- picking ------------------------------------------------------------------------------------
680
+
681
+ const raycaster = new Raycast()
682
+
683
+ /** Topmost entity under a viewport pixel: triangle-precise where OGL's raycast can read the
684
+ * geometry (non-interleaved positions), per-mesh bounds otherwise. 0 = nothing. */
685
+ const pickEntity = (x: number, y: number): number => {
686
+ const sceneId = core.activeSceneId()
687
+ const root = ctx.sceneRoots.get(sceneId)
688
+ const scene = core.scenes.get(sceneId)
689
+ if (!root || !scene || scene.camera === undefined) return 0
690
+ const { origin, dir } = ctx.getViewRay(sceneId, x, y)
691
+ ;(raycaster.origin as any).set(origin[0], origin[1], origin[2])
692
+ ;(raycaster.direction as any).set(dir[0], dir[1], dir[2])
693
+ const precise: Mesh[] = []
694
+ const coarse: Mesh[] = []
695
+ for (const m of ctx.collectMeshes(root) as any[]) {
696
+ const pos = m.geometry?.attributes?.position
697
+ if (!pos?.data) continue
698
+ // OGL's triangle test reads positions at index*size — interleaved (strided) data would
699
+ // misread, so those meshes fall back to their bounds
700
+ const strided = pos.stride && pos.stride !== pos.size * pos.data.BYTES_PER_ELEMENT
701
+ if (m.mode === gl.TRIANGLES && !strided) precise.push(m)
702
+ else coarse.push(m)
703
+ }
704
+ root.updateMatrixWorld()
705
+ let best: any = null
706
+ try {
707
+ for (const hit of raycaster.intersectMeshes(precise, { cullFace: false, includeUV: false, includeNormal: false, output: [] }) as any[]) {
708
+ if (!best || hit.hit.distance < best.hit.distance) best = hit
709
+ }
710
+ } catch { /* a malformed geometry must not break clicking */ }
711
+ try {
712
+ for (const hit of raycaster.intersectBounds(coarse, { output: [] }) as any[]) {
713
+ if (!best || hit.hit.distance < best.hit.distance) best = hit
714
+ }
715
+ } catch { /* ditto */ }
716
+ return best?._entId ?? 0
717
+ }
718
+
719
+ // ---- pointer wiring -----------------------------------------------------------------------------
720
+ // The canvas is a child of the root the touch system listens on: stopping propagation on a gizmo
721
+ // grab keeps the orbit track from starting; anything else falls through untouched.
722
+
723
+ let pendingClick: { pointerId: number, x: number, y: number } | null = null
724
+
725
+ const onPointerDown = (e: PointerEvent) => {
726
+ if (e.button !== 0 || e.shiftKey || dragging) return
727
+ const [x, y] = toCanvasXY(e)
728
+ const axis = hitTestGizmo(x, y)
729
+ if (axis !== null && beginDrag(axis, e)) {
730
+ e.preventDefault()
731
+ e.stopPropagation()
732
+ pendingClick = null
733
+ return
734
+ }
735
+ pendingClick = { pointerId: e.pointerId, x: e.clientX, y: e.clientY }
736
+ }
737
+
738
+ const onPointerUp = (e: PointerEvent) => {
739
+ const pending = pendingClick
740
+ pendingClick = null
741
+ if (!pending || pending.pointerId !== e.pointerId) return
742
+ if (Math.abs(e.clientX - pending.x) + Math.abs(e.clientY - pending.y) > CLICK_SLOP_PX) return
743
+ const [x, y] = toCanvasXY(e)
744
+ // an anchored gizmo (an empty's cross, a camera's frustum) draws on top, so it picks on top
745
+ handlers.onPick?.(pickAnchoredGizmo(x, y) || pickEntity(x, y))
746
+ }
747
+
748
+ const onPointerMove = (e: PointerEvent) => {
749
+ if (dragging) return
750
+ const [x, y] = toCanvasXY(e)
751
+ const next = mode !== null && selectedEntity !== null ? hitTestGizmo(x, y) : null
752
+ const overGizmo = next === null && anchoredGizmos.length > 0 ? pickAnchoredGizmo(x, y) || null : null
753
+ if (next !== hoverAxis || overGizmo !== hoverGizmoEntity) {
754
+ hoverAxis = next
755
+ hoverGizmoEntity = overGizmo
756
+ canvas.style.cursor = next !== null || overGizmo !== null ? 'pointer' : ''
757
+ }
758
+ }
759
+
760
+ canvas.addEventListener('pointerdown', onPointerDown)
761
+ canvas.addEventListener('pointerup', onPointerUp)
762
+ canvas.addEventListener('pointermove', onPointerMove)
763
+
764
+ // ---- collider overlay ---------------------------------------------------------------------------
765
+ // Wire pictures of the document's Shape aspects: primitives as LINES geometries (unit shapes
766
+ // scaled through the matrix where one parameter suffices, cached per parameter set otherwise),
767
+ // mesh colliders as the drawables' edge wireframe (the same companion geometry the wireframe
768
+ // mode uses — a triangle mesh IS the render mesh), a convex hull as the REAL hull Jolt builds
769
+ // (hullGeometryFor below). Drawn in the overlay pass with depth testing OFF: a collider usually
770
+ // sits inside or exactly on the surface it protects, and a depth-tested line would z-fight or
771
+ // hide — "where is my collider" beats occlusion correctness here.
772
+
773
+ const colliderProgram = new Program(gl, {
774
+ vertex: LINE_VERTEX,
775
+ fragment: LINE_FRAGMENT,
776
+ uniforms: { uColor: { value: COLLIDER_COLOR }, uAlpha: { value: COLLIDER_ALPHA } } as any,
777
+ transparent: true,
778
+ depthTest: false,
779
+ depthWrite: false,
780
+ })
781
+ colliderProgram.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
782
+
783
+ const linesGeometry = (positions: number[]): Geometry =>
784
+ new Geometry(gl, { position: { size: 3, data: Float32Array.from(positions) } } as any)
785
+ /** Append a LINES ring of radius r in the plane normal to `axis`, offset `at` along it. */
786
+ const ring = (out: number[], r: number, at: number, axis: 0 | 1 | 2, segs = 32): void => {
787
+ for (let i = 0; i < segs; i++) {
788
+ for (const a of [(Math.PI * 2 * i) / segs, (Math.PI * 2 * (i + 1)) / segs]) {
789
+ const u = Math.cos(a) * r, v = Math.sin(a) * r
790
+ if (axis === 1) out.push(u, at, v)
791
+ else if (axis === 0) out.push(at, u, v)
792
+ else out.push(u, v, at)
793
+ }
794
+ }
795
+ }
796
+ // unit box [-1,1]³ and unit sphere (three great circles) — sized through the matrix
797
+ const unitBox = linesGeometry((() => {
798
+ const p: number[] = []
799
+ const corner = (i: number) => [i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1]
800
+ for (let i = 0; i < 8; i++) for (const bit of [1, 2, 4]) {
801
+ if (i & bit) continue
802
+ p.push(...corner(i), ...corner(i | bit))
803
+ }
804
+ return p
805
+ })())
806
+ const unitSphere = linesGeometry((() => {
807
+ const p: number[] = []
808
+ ring(p, 1, 0, 0); ring(p, 1, 0, 1); ring(p, 1, 0, 2)
809
+ return p
810
+ })())
811
+ // Y-aligned cylinders/capsules depend on two parameters — cached per pair (bounded)
812
+ const tubeCache = new Map<string, Geometry>()
813
+ const tubeGeometry = (kind: 'cylinder' | 'capsule', hh: number, r: number): Geometry => {
814
+ const key = `${kind}:${hh}:${r}`
815
+ let g = tubeCache.get(key)
816
+ if (g) return g
817
+ const p: number[] = []
818
+ ring(p, r, hh, 1); ring(p, r, -hh, 1)
819
+ for (const [x, z] of [[r, 0], [-r, 0], [0, r], [0, -r]]) p.push(x, -hh, z, x, hh, z)
820
+ if (kind === 'capsule') {
821
+ // hemispherical caps: half arcs in the XY and YZ planes, top and bottom
822
+ const n = 12
823
+ for (const top of [true, false]) for (const plane of [0, 2]) {
824
+ for (let i = 0; i < n; i++) {
825
+ for (const a of [(Math.PI * i) / n, (Math.PI * (i + 1)) / n]) {
826
+ const u = Math.cos(a) * r, v = (top ? hh : -hh) + Math.sin(a) * r * (top ? 1 : -1)
827
+ if (plane === 0) p.push(u, v, 0)
828
+ else p.push(0, v, u)
829
+ }
830
+ }
831
+ }
832
+ }
833
+ g = linesGeometry(p)
834
+ if (tubeCache.size > 64) tubeCache.clear()
835
+ tubeCache.set(key, g)
836
+ return g
837
+ }
838
+
839
+ // ---- editor gizmos (world-space line batches from the SDK's `Gizmos` calls) --------------------
840
+ // Rebuilt only when the host replaces the list (the points are world space — no per-frame
841
+ // matrix work); one Mesh per color batch sharing the collider line program with its own uniforms.
842
+ const lineGizmoRoot = new Transform()
843
+ const lineGizmoMeshes: Mesh[] = []
844
+ /** Anchored batches: matrix + tint refresh every frame, hit-tested on clicks/hover. */
845
+ type AnchoredGizmo = { entityId: number, mesh: Mesh, program: Program, color: [number, number, number], segments: Float32Array }
846
+ const anchoredGizmos: AnchoredGizmo[] = []
847
+ let hoverGizmoEntity: number | null = null
848
+ /** A batch color (any CSS color — the SDK sends '#rrggbbaa', other editors their own strings), gray when not one. */
849
+ const GIZMO_GRAY: [number, number, number, number] = [0.81, 0.83, 0.87, 1]
850
+ const setGizmos = (list: EditorGizmoBatch[]): void => {
851
+ for (const m of lineGizmoMeshes) { m.setParent(null as any); (m.geometry as any).remove?.() }
852
+ lineGizmoMeshes.length = 0
853
+ anchoredGizmos.length = 0
854
+ hoverGizmoEntity = null
855
+ for (const b of Array.isArray(list) ? list : []) {
856
+ if (!b || !Array.isArray(b.segments) || b.segments.length < 6) continue
857
+ const [r, g, bl, a] = Color.tryParse(b.color) ?? GIZMO_GRAY
858
+ const color: [number, number, number] = [r, g, bl]
859
+ const alpha = Math.max(0, Math.min(1, Number(b.alpha ?? 1) * a))
860
+ const program = new Program(gl, {
861
+ vertex: LINE_VERTEX,
862
+ fragment: LINE_FRAGMENT,
863
+ uniforms: { uColor: { value: color.slice() }, uAlpha: { value: alpha } } as any,
864
+ transparent: true,
865
+ depthTest: false,
866
+ depthWrite: false,
867
+ })
868
+ program.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
869
+ const count = Math.floor(b.segments.length / 6) * 6
870
+ const segments = Float32Array.from(b.segments.slice(0, count))
871
+ const geometry = new Geometry(gl, { position: { size: 3, data: segments } } as any)
872
+ const m = new Mesh(gl, { geometry, program, mode: gl.LINES })
873
+ m.frustumCulled = false
874
+ m.setParent(lineGizmoRoot)
875
+ lineGizmoMeshes.push(m)
876
+ const entityId = Number(b.entityId ?? 0)
877
+ if (entityId > 0) {
878
+ m.matrixAutoUpdate = false
879
+ anchoredGizmos.push({ entityId, mesh: m, program, color, segments })
880
+ }
881
+ }
882
+ }
883
+
884
+ /** Per frame: place every anchored batch on its entity (scale stripped — a marker keeps its
885
+ * size on a scaled empty) and tint it: selection accent when selected, lifted when hovered. */
886
+ const syncAnchoredGizmos = (): void => {
887
+ for (const a of anchoredGizmos) {
888
+ if (!core.entities.has(a.entityId)) { a.mesh.visible = false; continue }
889
+ a.mesh.visible = true
890
+ a.mesh.matrix.copy(worldNoScale(a.entityId) as any)
891
+ a.mesh.worldMatrixNeedsUpdate = true
892
+ const tint = a.entityId === selectedEntity ? OUTLINE_COLOR
893
+ : a.entityId === hoverGizmoEntity ? highlight(a.color) : a.color
894
+ const u = (a.program.uniforms as any).uColor.value as number[]
895
+ u[0] = tint[0]; u[1] = tint[1]; u[2] = tint[2]
896
+ }
897
+ }
898
+
899
+ /** The anchored gizmo under a viewport point (logical px), or 0. Ray-vs-segment distance in
900
+ * world units against a tolerance of GIZMO_PICK_PX at the segment's depth — the same size on
901
+ * screen whatever the zoom — and ignoring occlusion, exactly like the lines draw. Among hits,
902
+ * the closest to the pointer wins, then the nearest to the camera. */
903
+ const pickAnchoredGizmo = (x: number, y: number): number => {
904
+ if (anchoredGizmos.length === 0) return 0
905
+ const sceneId = core.activeSceneId()
906
+ const scene = core.scenes.get(sceneId)
907
+ if (!scene || scene.camera === undefined) return 0
908
+ const { origin, dir } = ctx.getViewRay(sceneId, x, y)
909
+ const rect = canvas.getBoundingClientRect()
910
+ const heightPx = Math.max(1, rect.height / viewScale(canvas))
911
+ const tolPerDepth = (2 * Math.tan((core.projectionOf(sceneId).fov * Math.PI) / 360) * GIZMO_PICK_PX) / heightPx
912
+ let bestId = 0, bestRatio = 1, bestDepth = Infinity
913
+ const a: V3 = [0, 0, 0], b: V3 = [0, 0, 0]
914
+ for (const g of anchoredGizmos) {
915
+ if (!core.entities.has(g.entityId)) continue
916
+ const m = worldNoScale(g.entityId)
917
+ const seg = g.segments
918
+ for (let i = 0; i + 5 < seg.length; i += 6) {
919
+ for (let k = 0; k < 3; k++) {
920
+ a[k] = m[k] * seg[i] + m[4 + k] * seg[i + 1] + m[8 + k] * seg[i + 2] + m[12 + k]
921
+ b[k] = m[k] * seg[i + 3] + m[4 + k] * seg[i + 4] + m[8 + k] * seg[i + 5] + m[12 + k]
922
+ }
923
+ // closest points between the ray (t ≥ 0) and the segment (u ∈ [0,1])
924
+ const ab = sub3(b, a), ao = sub3(a, origin)
925
+ const dd = dot3(dir, dir), dab = dot3(dir, ab), abab = dot3(ab, ab)
926
+ const dao = dot3(dir, ao), abao = dot3(ab, ao)
927
+ const denom = dd * abab - dab * dab
928
+ let u = denom > 1e-12 ? (dab * dao - dd * abao) / denom : 0
929
+ u = Math.max(0, Math.min(1, u))
930
+ const t = Math.max(0, (dao + u * dab) / (dd || 1))
931
+ const q = add3(a, scale3(ab, u))
932
+ const r = add3(origin, scale3(dir, t))
933
+ if (t <= 1e-6) continue // behind the camera
934
+ const tol = t * tolPerDepth
935
+ const ratio = len3(sub3(q, r)) / tol
936
+ if (ratio > 1) continue
937
+ if (ratio < bestRatio - 1e-3 || (Math.abs(ratio - bestRatio) <= 1e-3 && t < bestDepth)) {
938
+ bestId = g.entityId; bestRatio = ratio; bestDepth = t
939
+ }
940
+ }
941
+ }
942
+ return bestId
943
+ }
944
+
945
+ let colliders: EditorCollider[] = []
946
+ const colliderRoot = new Transform()
947
+ const colliderMeshes: Mesh[] = []
948
+ /** The entity's world matrix with its scale stripped (explicit Shape dims are world units), plus
949
+ * the scale that was taken out (the `auto` box measures in node-local units and scales itself). */
950
+ const worldScale: [number, number, number] = [1, 1, 1]
951
+ const worldNoScale = (id: number): Float32Array => {
952
+ const out = Float32Array.from(core.worldMatrix(id) as ArrayLike<number>)
953
+ for (let c = 0; c < 3; c++) {
954
+ const l = Math.hypot(out[c * 4], out[c * 4 + 1], out[c * 4 + 2]) || 1
955
+ worldScale[c] = l
956
+ out[c * 4] /= l; out[c * 4 + 1] /= l; out[c * 4 + 2] /= l
957
+ }
958
+ return out
959
+ }
960
+ /** m × translate(t) × scale(s) — place a unit geometry. */
961
+ const placed = (m: Float32Array, sx: number, sy: number, sz: number, tx = 0, ty = 0, tz = 0): Float32Array => {
962
+ const out = Float32Array.from(m)
963
+ out[12] = m[12] + m[0] * tx + m[4] * ty + m[8] * tz
964
+ out[13] = m[13] + m[1] * tx + m[5] * ty + m[9] * tz
965
+ out[14] = m[14] + m[2] * tx + m[6] * ty + m[10] * tz
966
+ for (let i = 0; i < 3; i++) { out[i] *= sx; out[4 + i] *= sy; out[8 + i] *= sz }
967
+ return out
968
+ }
969
+ /** Triangle drawables of an entity subtree (what a mesh collider is built from). */
970
+ const collectTriangles = (id: number, out: Mesh[]): void => {
971
+ const e = core.entities.get(id)
972
+ if (!e) return
973
+ for (const d of ctx.drawablesOf(id)) if ((d as any).mode === gl.TRIANGLES) out.push(d)
974
+ for (const c of e.children) collectTriangles(c, out)
975
+ }
976
+
977
+ // A `Shape { mesh: 'convex' }`'s picture is the hull the SIMULATION builds — creator-physics' own
978
+ // hull builder (Jolt: the 256-point cap, its tolerances) over the subtree's triangles brought into
979
+ // the node's unscaled frame — never a wireframe of the render mesh. Cached per collider entity on
980
+ // (drawable geometries, world scale): a gizmo drag moves the cached lines, a re-attach rebuilds.
981
+ const hullCache = new Map<number, { key: string, geometry: Geometry | null }>()
982
+ const hullLines = (tris: Float32Array): Geometry => {
983
+ const index = new Map<string, number>()
984
+ const verts: number[] = []
985
+ const vertexOf = (x: number, y: number, z: number): number => {
986
+ const k = `${x.toFixed(5)},${y.toFixed(5)},${z.toFixed(5)}`
987
+ let i = index.get(k)
988
+ if (i === undefined) { i = verts.length / 3; index.set(k, i); verts.push(x, y, z) }
989
+ return i
990
+ }
991
+ const edges = new Set<number>()
992
+ for (let t = 0; t + 8 < tris.length; t += 9) {
993
+ const a = vertexOf(tris[t], tris[t + 1], tris[t + 2])
994
+ const b = vertexOf(tris[t + 3], tris[t + 4], tris[t + 5])
995
+ const c = vertexOf(tris[t + 6], tris[t + 7], tris[t + 8])
996
+ for (const [p, q] of [[a, b], [b, c], [c, a]]) edges.add(p < q ? p * 65536 + q : q * 65536 + p)
997
+ }
998
+ const lines: number[] = []
999
+ for (const e of edges) {
1000
+ const p = Math.floor(e / 65536) * 3, q = (e % 65536) * 3
1001
+ lines.push(verts[p], verts[p + 1], verts[p + 2], verts[q], verts[q + 1], verts[q + 2])
1002
+ }
1003
+ return new Geometry(gl, { position: { size: 3, data: Float32Array.from(lines) } } as any)
1004
+ }
1005
+ const hullGeometryFor = (entityId: number): Geometry | null => {
1006
+ const tris: Mesh[] = []
1007
+ collectTriangles(entityId, tris)
1008
+ const node = worldNoScale(entityId) // fills worldScale
1009
+ const key = tris.map((d: any) => d.geometry?.id ?? '?').join(',') + '|' + worldScale.map((s) => s.toFixed(4)).join(',')
1010
+ const cached = hullCache.get(entityId)
1011
+ if (cached && cached.key === key) return cached.geometry
1012
+ const inv = invertTRS(node)
1013
+ const points: number[] = []
1014
+ for (const d of tris as any[]) {
1015
+ const attr = d.geometry?.attributes?.position
1016
+ const data = attr?.data
1017
+ if (!(data instanceof Float32Array) || attr.size !== 3 || attr.stride) continue
1018
+ const m = multiply(new Float32Array(16), inv, d.worldMatrix as any)
1019
+ for (let i = 0; i + 2 < data.length; i += 3) {
1020
+ const x = data[i], y = data[i + 1], z = data[i + 2]
1021
+ points.push(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
1022
+ }
1023
+ }
1024
+ const hull = points.length >= 12 ? convexHullTriangles(points) : null
1025
+ const geometry = hull ? hullLines(hull) : null
1026
+ hullCache.set(entityId, { key, geometry })
1027
+ return geometry
1028
+ }
1029
+
1030
+ /** Rebuild the overlay meshes from the collider list + current entity transforms (every frame
1031
+ * the overlay shows — entities move under gizmo drags, async GLB loads add drawables). */
1032
+ const syncColliders = (): boolean => {
1033
+ for (const m of colliderMeshes) m.setParent(null as any)
1034
+ colliderMeshes.length = 0
1035
+ const add = (geometry: Geometry, matrix: Float32Array): void => {
1036
+ const m = new Mesh(gl, { geometry, program: colliderProgram, mode: gl.LINES })
1037
+ m.frustumCulled = false
1038
+ m.matrixAutoUpdate = false
1039
+ m.matrix.copy(matrix as any)
1040
+ m.setParent(colliderRoot)
1041
+ colliderMeshes.push(m)
1042
+ }
1043
+ for (const c of colliders) {
1044
+ if (!core.entities.has(c.entityId)) continue
1045
+ const shape = c.shape
1046
+ const o = c.origin
1047
+ const [ox, oy, oz] = o ?? [0, 0, 0]
1048
+ if (shape.kind === 'box') {
1049
+ add(unitBox, placed(worldNoScale(c.entityId), shape.half[0], shape.half[1], shape.half[2], ox, oy, oz))
1050
+ } else if (shape.kind === 'sphere') {
1051
+ add(unitSphere, placed(worldNoScale(c.entityId), shape.radius, shape.radius, shape.radius, ox, oy, oz))
1052
+ } else if (shape.kind === 'cylinder' || shape.kind === 'capsule') {
1053
+ add(tubeGeometry(shape.kind, shape.halfHeight, shape.radius), placed(worldNoScale(c.entityId), 1, 1, 1, ox, oy, oz))
1054
+ } else if (shape.kind === 'mesh' && shape.convex && hullGeometryFor(c.entityId)) {
1055
+ add(hullGeometryFor(c.entityId)!, placed(worldNoScale(c.entityId), 1, 1, 1, ox, oy, oz))
1056
+ } else if (shape.kind === 'mesh') {
1057
+ const tris: Mesh[] = []
1058
+ collectTriangles(c.entityId, tris)
1059
+ // the origin shifts the whole collider in the NODE's unscaled frame — move each drawable by
1060
+ // that world-space delta rather than by its own basis (a rotated child would skew otherwise)
1061
+ const n = worldNoScale(c.entityId)
1062
+ const dx = n[0] * ox + n[4] * oy + n[8] * oz
1063
+ const dy = n[1] * ox + n[5] * oy + n[9] * oz
1064
+ const dz = n[2] * ox + n[6] * oy + n[10] * oz
1065
+ for (const d of tris as any[]) {
1066
+ if (!d.geometry?.attributes?.position) continue
1067
+ const m = Float32Array.from(d.worldMatrix as ArrayLike<number>)
1068
+ m[12] += dx; m[13] += dy; m[14] += dz
1069
+ add(ctx.wireGeometryFor(d.geometry), m)
1070
+ }
1071
+ } else {
1072
+ // auto: the node's OWN geometry AABB (a Mesh node) × world scale, centred on those bounds;
1073
+ // anything else gets the SDK's 0.5 default. An explicit origin REPLACES the measured centre
1074
+ // (the SDK's rule), so both live in world units on the unscaled basis.
1075
+ const own = ctx.drawablesOf(c.entityId).find((d: any) => d.mode === gl.TRIANGLES) as any
1076
+ let hx = 0.5, hy = 0.5, hz = 0.5, cx = 0, cy = 0, cz = 0
1077
+ const b = own?.geometry?.bounds
1078
+ if (b && isFinite(b.min.x)) {
1079
+ hx = (b.max.x - b.min.x) / 2; hy = (b.max.y - b.min.y) / 2; hz = (b.max.z - b.min.z) / 2
1080
+ cx = (b.max.x + b.min.x) / 2; cy = (b.max.y + b.min.y) / 2; cz = (b.max.z + b.min.z) / 2
1081
+ }
1082
+ const m = worldNoScale(c.entityId) // fills worldScale
1083
+ const [sx, sy, sz] = worldScale
1084
+ add(unitBox, o
1085
+ ? placed(m, hx * sx, hy * sy, hz * sz, ox, oy, oz)
1086
+ : placed(m, hx * sx, hy * sy, hz * sz, cx * sx, cy * sy, cz * sz))
1087
+ }
1088
+ }
1089
+ return colliderMeshes.length > 0
1090
+ }
1091
+
1092
+ // ---- selection outline --------------------------------------------------------------------------
1093
+ // Two small passes after everything else: (1) the selected subtree's drawables into an offscreen
1094
+ // mask (constant white, no depth — the silhouette shows through occluders), (2) a fullscreen
1095
+ // composite that tints pixels near-but-outside the mask. Skinned drawables pose correctly via the
1096
+ // bone texture; morphed drawables mask at rest pose (same degrade as the shadow pass).
1097
+
1098
+ const maskProgram = new Program(gl, {
1099
+ vertex: depthVertex(false),
1100
+ fragment: depthFragment,
1101
+ uniforms: { modelMatrix: { value: identity() }, shadowViewProjection: { value: identity() } } as any,
1102
+ depthTest: false,
1103
+ depthWrite: false,
1104
+ cullFace: false as any,
1105
+ })
1106
+ const maskSkinProgram = new Program(gl, {
1107
+ vertex: depthVertex(true),
1108
+ fragment: depthFragment,
1109
+ uniforms: {
1110
+ modelMatrix: { value: identity() },
1111
+ shadowViewProjection: { value: identity() },
1112
+ boneTexture: { value: null },
1113
+ boneTextureSize: { value: 4 },
1114
+ } as any,
1115
+ depthTest: false,
1116
+ depthWrite: false,
1117
+ cullFace: false as any,
1118
+ })
1119
+ let maskTarget: RenderTarget | null = null
1120
+ const outlineProgram = new Program(gl, {
1121
+ vertex: OUTLINE_VERTEX,
1122
+ fragment: OUTLINE_FRAGMENT,
1123
+ uniforms: {
1124
+ tMask: { value: null },
1125
+ uTexel: { value: [0, 0] },
1126
+ uColor: { value: OUTLINE_COLOR },
1127
+ } as any,
1128
+ depthTest: false,
1129
+ depthWrite: false,
1130
+ transparent: true,
1131
+ })
1132
+ outlineProgram.setBlendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
1133
+ const outlineTriangle = new Mesh(gl, { geometry: new Triangle(gl), program: outlineProgram })
1134
+
1135
+ /** Visible drawables of the selected entity's subtree (wire companions are not drawables). */
1136
+ const collectSelected = (id: number, out: Mesh[]): void => {
1137
+ const e = core.entities.get(id)
1138
+ if (!e || !e.visible) return
1139
+ out.push(...ctx.drawablesOf(id))
1140
+ for (const c of e.children) collectSelected(c, out)
1141
+ }
1142
+
1143
+ const renderOutline = (): void => {
1144
+ if (selectedEntity === null || !core.entities.has(selectedEntity)) return
1145
+ const meshes: Mesh[] = []
1146
+ collectSelected(selectedEntity, meshes)
1147
+ if (meshes.length === 0) return
1148
+
1149
+ if (!maskTarget || maskTarget.width !== canvas.width || maskTarget.height !== canvas.height) {
1150
+ maskTarget = new RenderTarget(gl, { width: Math.max(1, canvas.width), height: Math.max(1, canvas.height) })
1151
+ }
1152
+ const camVP = multiply(new Float32Array(16),
1153
+ camera.projectionMatrix as unknown as Mat4Like, camera.viewMatrix as unknown as Mat4Like)
1154
+ maskProgram.uniforms.shadowViewProjection.value = camVP
1155
+ maskSkinProgram.uniforms.shadowViewProjection.value = camVP
1156
+
1157
+ renderer.bindFramebuffer(maskTarget)
1158
+ renderer.setViewport(maskTarget.width, maskTarget.height)
1159
+ gl.clearColor(0, 0, 0, 0)
1160
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
1161
+ const identityMat = identity()
1162
+ for (const mesh of meshes as any[]) {
1163
+ const isSkin = mesh instanceof GLTFSkin
1164
+ const program = isSkin ? maskSkinProgram : maskProgram
1165
+ program.uniforms.modelMatrix.value = isSkin ? identityMat : mesh.worldMatrix
1166
+ const original = mesh.program
1167
+ mesh.program = program
1168
+ mesh.draw({})
1169
+ mesh.program = original
1170
+ }
1171
+ renderer.bindFramebuffer({ buffer: null } as any)
1172
+ renderer.setViewport(canvas.width, canvas.height)
1173
+
1174
+ outlineProgram.uniforms.tMask.value = maskTarget.texture
1175
+ outlineProgram.uniforms.uTexel.value = [1 / maskTarget.width, 1 / maskTarget.height]
1176
+ outlineTriangle.draw({})
1177
+ }
1178
+
1179
+ // ---- render hooks (called by createViewerLite's renderFrame) ------------------------------------
1180
+
1181
+ /** After the main pass, before overlays: the grid, depth-tested against scene content.
1182
+ * `background` is the display-space clear color of this frame — the grid tints against it. */
1183
+ const renderScenePass = (background: readonly number[]): void => {
1184
+ if (!gridVisible) return
1185
+ setGridBackground(background)
1186
+ renderer.render({ scene: gridRoot, camera, clear: false, frustumCull: false, sort: false })
1187
+ }
1188
+
1189
+ /** After the overlays: outline, then the gizmo on a fresh depth buffer (always on top). */
1190
+ const renderOverlayPass = (): void => {
1191
+ renderOutline()
1192
+ if (colliders.length > 0 && syncColliders()) {
1193
+ renderer.render({ scene: colliderRoot, camera, clear: false, frustumCull: false, sort: false })
1194
+ }
1195
+ if (lineGizmoMeshes.length > 0) {
1196
+ syncAnchoredGizmos()
1197
+ renderer.render({ scene: lineGizmoRoot, camera, clear: false, frustumCull: false, sort: false })
1198
+ }
1199
+ if (!syncGizmo()) return
1200
+ // glDepthMask MASKS depth clears — the outline pass just ran with depthWrite off, so restore
1201
+ // it through the renderer's state cache or the clear silently no-ops (gizmo ends up occluded).
1202
+ renderer.setDepthMask(true)
1203
+ gl.clear(gl.DEPTH_BUFFER_BIT)
1204
+ renderer.render({ scene: gizmoRoot, camera, clear: false, frustumCull: false, sort: false })
1205
+ }
1206
+
1207
+ // ---- camera preview inset -----------------------------------------------------------------------
1208
+ let preview: EditorPreview | null = null
1209
+ const previewCamera = new Camera(gl, { fov: 60, near: 0.01, far: 1000, aspect: 1 })
1210
+ previewCamera.matrixAutoUpdate = false // posed by matrix (like the main camera), never from position/quaternion
1211
+
1212
+ /** After everything else: the inset, drawn straight from OGL's render list with the viewport +
1213
+ * scissor set to the rect (renderer.render() pins the viewport to the full canvas for a null
1214
+ * target, and the multisampled default framebuffer refuses blits — so neither a scissored
1215
+ * render() nor an offscreen target + blit works). */
1216
+ const renderPreviewPass = (root: Transform, background: readonly number[]): void => {
1217
+ const p = preview
1218
+ if (!p) return
1219
+ const s = canvas.width / (canvas.getBoundingClientRect().width || canvas.width) // CSS px → device px
1220
+ const x = Math.round(p.rect[0] * s), y = Math.round(p.rect[1] * s)
1221
+ const w = Math.round(p.rect[2] * s), h = Math.round(p.rect[3] * s)
1222
+ if (w < 2 || h < 2) return
1223
+ const wm = core.worldMatrixF(p.entityId)
1224
+ if (!wm) return
1225
+ for (let i = 0; i < 16; i++) (previewCamera.matrix as any)[i] = wm[i]
1226
+ previewCamera.worldMatrixNeedsUpdate = true
1227
+ previewCamera.perspective({ fov: p.fov, aspect: w / h, near: p.near, far: p.far })
1228
+ previewCamera.updateMatrixWorld()
1229
+
1230
+ const hidden: Mesh[] = []
1231
+ for (const id of p.hide ?? []) {
1232
+ for (const m of ctx.drawablesOf(id)) if (m.visible) { m.visible = false; hidden.push(m) }
1233
+ }
1234
+ const y0 = canvas.height - y - h // GL origin bottom-left
1235
+ renderer.bindFramebuffer({ buffer: null } as any)
1236
+ renderer.setViewport(w, h, x, y0)
1237
+ gl.enable(gl.SCISSOR_TEST)
1238
+ gl.scissor(x, y0, w, h)
1239
+ gl.clearColor(background[0] ?? 0, background[1] ?? 0, background[2] ?? 0, 1)
1240
+ renderer.enable(gl.DEPTH_TEST)
1241
+ renderer.setDepthMask(true)
1242
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
1243
+ ctx.renderScene(root, previewCamera, hidden.length > 0)
1244
+ gl.disable(gl.SCISSOR_TEST)
1245
+ for (const m of hidden) m.visible = true
1246
+ renderer.setViewport(canvas.width, canvas.height)
1247
+ }
1248
+
1249
+ const dispose = (): void => {
1250
+ dragging?.cleanup()
1251
+ dragging = null
1252
+ canvas.removeEventListener('pointerdown', onPointerDown)
1253
+ canvas.removeEventListener('pointerup', onPointerUp)
1254
+ canvas.removeEventListener('pointermove', onPointerMove)
1255
+ canvas.style.cursor = ''
1256
+ }
1257
+
1258
+ // ---- the host-facing API (what _editorConnect returns) ------------------------------------------
1259
+
1260
+ const api = {
1261
+ setGrid(on: boolean): void { gridVisible = on === true },
1262
+ setSelection(entityId: number | null): void {
1263
+ if (dragging && entityId !== selectedEntity) {
1264
+ dragging.cleanup()
1265
+ dragging = null
1266
+ }
1267
+ selectedEntity = entityId && entityId > 0 ? entityId : null
1268
+ },
1269
+ setGizmoMode(next: GizmoMode | null): void {
1270
+ mode = next === 'translate' || next === 'rotate' || next === 'scale' ? next : null
1271
+ hoverAxis = null
1272
+ },
1273
+ setGizmoSpace(next: GizmoSpace): void { space = next === 'local' ? 'local' : 'global' },
1274
+ /** Replace the drawn collider set (empty = none). Unknown entities are skipped. */
1275
+ setColliders(list: EditorCollider[]): void { colliders = Array.isArray(list) ? list.slice() : [] },
1276
+ /** Replace the drawn editor gizmo lines (empty = none). Anchored batches (`entityId`) follow
1277
+ * their entity, pick and tint with the selection. */
1278
+ setGizmos,
1279
+ /** The camera preview inset (null = off). */
1280
+ setPreview(next: EditorPreview | null): void { preview = next && next.entityId > 0 ? next : null },
1281
+ /** The topmost scene entity under a viewport pixel (0 = empty space) — the host's drop
1282
+ * targets (a material tile dropped on an object). Same pick the click path uses. */
1283
+ pickAt(x: number, y: number): number { return pickEntity(x, y) },
1284
+ }
1285
+
1286
+ return {
1287
+ api,
1288
+ setHandlers(next: EditorHandlers): void { handlers = next ?? {} },
1289
+ renderScenePass,
1290
+ renderOverlayPass,
1291
+ renderPreviewPass,
1292
+ dispose,
1293
+ }
1294
+ }
1295
+
1296
+ export type EditorLayer = ReturnType<typeof createEditorLayer>
1297
+ export type EditorApi = EditorLayer['api']