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.
- package/README.md +27 -0
- package/package.json +33 -0
- package/src/assets/decoders/README.md +13 -0
- package/src/assets/decoders/basis_transcoder.js +19 -0
- package/src/assets/decoders/basis_transcoder.wasm +0 -0
- package/src/assets/decoders/draco_decoder.wasm +0 -0
- package/src/assets/decoders/draco_wasm_wrapper.js +117 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/core/animBridge.ts +51 -0
- package/src/core/boneNames.ts +22 -0
- package/src/core/frame.ts +230 -0
- package/src/core/glbStructure.ts +100 -0
- package/src/core/headlessBridge.ts +92 -0
- package/src/core/mapView.ts +195 -0
- package/src/core/math3d.ts +275 -0
- package/src/core/particleBridge.ts +154 -0
- package/src/core/physicsBridge.ts +305 -0
- package/src/core/recording.ts +59 -0
- package/src/core/sceneCore.ts +833 -0
- package/src/core/serialize.ts +144 -0
- package/src/gen/unsupported.gen.ts +212 -0
- package/src/index.ts +5 -0
- package/src/vendor/creator-anim/dist/creatorAnim.d.ts +123 -0
- package/src/vendor/creator-anim/dist/creatorAnim.js +14 -0
- package/src/vendor/creator-anim/js/index.ts +656 -0
- package/src/vendor/creator-nav/dist/creatorNav.d.ts +56 -0
- package/src/vendor/creator-nav/dist/creatorNav.js +14 -0
- package/src/vendor/creator-nav/js/index.ts +400 -0
- package/src/vendor/creator-particles/dist/creatorParticles.d.ts +43 -0
- package/src/vendor/creator-particles/dist/creatorParticles.js +14 -0
- package/src/vendor/creator-particles/js/index.ts +137 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.d.ts +107 -0
- package/src/vendor/creator-physics/dist/creatorPhysics.js +14 -0
- package/src/vendor/creator-physics/js/index.ts +515 -0
- package/src/vite-env.d.ts +1 -0
- package/src/webgl/batching.ts +453 -0
- package/src/webgl/createViewerLite.ts +1627 -0
- package/src/webgl/customMaterial.ts +228 -0
- package/src/webgl/decoders/basis.ts +123 -0
- package/src/webgl/decoders/draco.ts +110 -0
- package/src/webgl/decoders/index.ts +68 -0
- package/src/webgl/decoders/worker.ts +45 -0
- package/src/webgl/editor.ts +1297 -0
- package/src/webgl/glb.ts +593 -0
- package/src/webgl/ibl.ts +137 -0
- 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']
|