@solidrt/3d 0.0.46 → 0.0.48

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/src/glsl.ts ADDED
@@ -0,0 +1,112 @@
1
+ // The exported lighting GLSL: string constants an app composes into its
2
+ // own shaderMaterial sources with plain template literals - the same
3
+ // pieces the package's future lit materials will be built from, so a
4
+ // custom material never becomes second-class (no preprocessor, no include
5
+ // resolver; the policy is argued in okf/research/3d-differentiators.md).
6
+ //
7
+ // The light-model functions are PURE: normals, view vectors, light
8
+ // directions, colors and exponents all arrive as arguments, so these
9
+ // constants pin nothing but function names. The LIT_VERTEX pair are the
10
+ // deliberate exception - they pin the standard varying interface
11
+ // (vWorldPos, vNormal, vUv, plus vColor on the colored variant) and
12
+ // consume the standard uniform set (uModel, uViewProj, uNormal);
13
+ // fragments written against those names compose with them directly. All directions are expected normalized;
14
+ // every function returns its raw term, weighting and color belong to the
15
+ // caller.
16
+
17
+ import { glsl } from "@solidrt/core/gpu"
18
+
19
+ /**
20
+ * The standard lit vertex stage: clip position via uViewProj * uModel,
21
+ * with world position, world normal (via `mat3(uNormal)`, correct under
22
+ * non-uniform scale) and UV as varyings:
23
+ *
24
+ * in vec3 vWorldPos; in vec3 vNormal; in vec2 vUv;
25
+ *
26
+ * Pair it with your own fragment; the view vector there is
27
+ * `normalize(uCamPos - vWorldPos)`.
28
+ */
29
+ export const LIT_VERTEX = glsl`
30
+ in vec3 aPos;
31
+ in vec3 aNormal;
32
+ in vec2 aUV;
33
+ uniform mat4 uModel;
34
+ uniform mat4 uViewProj;
35
+ uniform mat4 uNormal;
36
+ out vec3 vWorldPos;
37
+ out vec3 vNormal;
38
+ out vec2 vUv;
39
+
40
+ void main() {
41
+ vec4 world = uModel * vec4(aPos, 1.0);
42
+ gl_Position = uViewProj * world;
43
+ vWorldPos = world.xyz;
44
+ vNormal = mat3(uNormal) * aNormal;
45
+ vUv = aUV;
46
+ }
47
+ `
48
+
49
+ /**
50
+ * LIT_VERTEX for "colored"-layout geometry: the same interface plus the
51
+ * per-vertex aColor vec4 forwarded raw as `in vec4 vColor` - what it means
52
+ * (a tint, baked AO in one channel, anything) is the fragment's business.
53
+ * Using this constant opts the material into the colored layout (its
54
+ * meshes need withColors() geometry), because shaderMaterial detects
55
+ * aColor in the vertex source.
56
+ */
57
+ export const LIT_VERTEX_COLORED = glsl`
58
+ in vec3 aPos;
59
+ in vec3 aNormal;
60
+ in vec2 aUV;
61
+ in vec4 aColor;
62
+ uniform mat4 uModel;
63
+ uniform mat4 uViewProj;
64
+ uniform mat4 uNormal;
65
+ out vec3 vWorldPos;
66
+ out vec3 vNormal;
67
+ out vec2 vUv;
68
+ out vec4 vColor;
69
+
70
+ void main() {
71
+ vec4 world = uModel * vec4(aPos, 1.0);
72
+ gl_Position = uViewProj * world;
73
+ vWorldPos = world.xyz;
74
+ vNormal = mat3(uNormal) * aNormal;
75
+ vUv = aUV;
76
+ vColor = aColor;
77
+ }
78
+ `
79
+
80
+ /** `vec3 hemisphere(vec3 n, vec3 sky, vec3 ground)` - ambient from a
81
+ * sky/ground gradient by the normal's vertical tilt: sky straight up,
82
+ * ground bounce straight down. */
83
+ export const HEMISPHERE = glsl`
84
+ vec3 hemisphere(vec3 n, vec3 sky, vec3 ground) {
85
+ return mix(ground, sky, n.y * 0.5 + 0.5);
86
+ }
87
+ `
88
+
89
+ /** `float lambert(vec3 n, vec3 l)` - the diffuse term for a directional
90
+ * light pointing TOWARD the light (multiply by your light color). */
91
+ export const LAMBERT = glsl`
92
+ float lambert(vec3 n, vec3 l) {
93
+ return max(dot(n, l), 0.0);
94
+ }
95
+ `
96
+
97
+ /** `float blinnSpecular(vec3 n, vec3 v, vec3 l, float shininess)` - the
98
+ * Blinn-Phong highlight from the half vector between view and light;
99
+ * shininess runs from wide matte sheen (~8) to tight mirror dot (~150). */
100
+ export const BLINN_SPECULAR = glsl`
101
+ float blinnSpecular(vec3 n, vec3 v, vec3 l, float shininess) {
102
+ return pow(max(dot(n, normalize(l + v)), 0.0), shininess);
103
+ }
104
+ `
105
+
106
+ /** `float fresnel(vec3 n, vec3 v, float power)` - the grazing-angle rim
107
+ * weight (1 at silhouettes, 0 face-on); typical power 3 to 5. */
108
+ export const FRESNEL = glsl`
109
+ float fresnel(vec3 n, vec3 v, float power) {
110
+ return pow(1.0 - max(dot(n, v), 0.0), power);
111
+ }
112
+ `
package/src/index.ts CHANGED
@@ -5,15 +5,21 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, createMesh, createScene, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible } from "./scene.ts"
8
+ export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible, worldPosition } from "./scene.ts"
9
9
  export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
10
- export { box, disposeGeometry, plane, sphere, torusKnot, FLOATS_PER_VERTEX, VERTEX_LAYOUT } from "./geometry.ts"
11
- export type { Geometry } from "./geometry.ts"
10
+ export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
11
+ export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
12
+ export { fillet, roundRect, shape, triangulate } from "./profile.ts"
13
+ export type { Profile, ProfilePoint } from "./profile.ts"
14
+ export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
15
+ export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
12
16
  export { shaderMaterial, unlit } from "./material.ts"
13
17
  export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
14
18
  export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
15
19
  export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
16
20
  export { createOrbitCamera } from "./orbit.ts"
17
21
  export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
18
- export { compose, copy, identity, lookAt, mat4, multiply, perspective } from "./math.ts"
19
- export type { Mat4, Vec3 } from "./math.ts"
22
+ // math's lookAt (the camera view matrix) stays on the /math subpath: the
23
+ // root's lookAt is the scene verb, the same split as `add`.
24
+ export { compose, copy, eulerFromQuat, identity, mat4, multiply, normalMatrix, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
25
+ export type { Mat4, Quat, Vec2, Vec3 } from "./math.ts"
package/src/material.ts CHANGED
@@ -36,7 +36,8 @@ import type {
36
36
  TextureId,
37
37
  Topology,
38
38
  } from "@solidrt/core/gpu"
39
- import { VERTEX_LAYOUT } from "./geometry.ts"
39
+ import { VERTEX_LAYOUTS } from "./geometry.ts"
40
+ import type { VertexLayout } from "./geometry.ts"
40
41
 
41
42
  export type Material = {
42
43
  /** The pipeline this material draws with (lazily created). */
@@ -45,6 +46,15 @@ export type Material = {
45
46
  params: ShaderParams
46
47
  /** Per-entry sampler bindings, when the material samples textures. */
47
48
  textures?: Record<string, TextureId>
49
+ /** True when the vertex stage declares `uNormal`: the scene then writes
50
+ * the world matrix's inverse-transpose alongside uModel for meshes using
51
+ * this material (set automatically by shaderMaterial). */
52
+ normalMatrix?: boolean
53
+ /** The vertex layout the pipeline is built for; absent means "standard".
54
+ * shaderMaterial sets "colored" when the vertex stage reads `aColor`. A
55
+ * mesh whose geometry layout differs is rejected at add() - the strides
56
+ * disagree, so a mismatch would render garbage, not just miss a channel. */
57
+ layout?: VertexLayout
48
58
  /** Present on materials that own their pipeline (shaderMaterial). */
49
59
  dispose?(): void
50
60
  }
@@ -97,7 +107,7 @@ function pipelineFor(kind: "color" | "map"): RenderPipelineId {
97
107
  })
98
108
  let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + kind })
99
109
  let pipeline = createRenderPipeline(program, {
100
- attributes: VERTEX_LAYOUT,
110
+ attributes: VERTEX_LAYOUTS.standard,
101
111
  depth: true,
102
112
  cull: "back",
103
113
  label: "scene-unlit-" + kind,
@@ -140,13 +150,24 @@ export type ShaderMaterialOptions = {
140
150
  * mesh's world matrix, written per entry whenever the mesh moves) and
141
151
  * `uniform mat4 uViewProj` (the camera's view-projection, shared by the
142
152
  * whole scene target and written once per camera move) - transform with
143
- * `uViewProj * uModel * vec4(aPos, 1.0)`. Declare any of the shared
153
+ * `uViewProj * uModel * vec4(aPos, 1.0)`; a source mentioning neither
154
+ * throws right here. The rest of the standard uniform set is opt-in by
155
+ * declare-and-use: `uniform mat4 uNormal` (either stage) receives the
156
+ * world inverse-transpose beside uModel - take `mat3(uNormal)` for
157
+ * normals, correct under non-uniform scale - and `uniform vec3 uCamPos`
158
+ * the camera's world position, shared like uViewProj (the specular /
159
+ * fresnel view vector: `uCamPos - worldPos`). Declare any of the
144
160
  * layout's `in` attributes (aPos vec3, aNormal vec3, aUV vec2);
145
- * undeclared ones are skipped.
161
+ * undeclared ones are skipped. Reading `in vec4 aColor` opts the
162
+ * material into the "colored" 12-float layout - the per-vertex data
163
+ * channel (tint, baked AO, any four scalars); its meshes then need
164
+ * withColors() geometry, and a layout mismatch throws at add().
165
+ * `@solidrt/3d/glsl` exports a standard
166
+ * vertex stage and lighting pieces built on exactly this contract.
146
167
  */
147
168
  vertex: string
148
169
  fragment: string
149
- /** Uniform seeds beyond uModel/uViewProj; update per mesh later with
170
+ /** Uniform seeds beyond the standard set; update per mesh later with
150
171
  * setMeshParams. */
151
172
  params?: ShaderParams
152
173
  textures?: Record<string, TextureId>
@@ -171,9 +192,25 @@ export type ShaderMaterialOptions = {
171
192
  * and `dispose()` it if the app is done with the look for good.
172
193
  */
173
194
  export function shaderMaterial(opts: ShaderMaterialOptions): Material {
195
+ // The standard-set contract, checked where the mistake is made: a vertex
196
+ // stage that never mentions the matrices cannot place meshes, and with
197
+ // shared params skipping undeclared names the omission would otherwise
198
+ // surface as a silently untransformed render, not an error.
199
+ for (let name of ["uModel", "uViewProj"]) {
200
+ if (!new RegExp("\\b" + name + "\\b").test(opts.vertex)) {
201
+ throw new Error(
202
+ "shaderMaterial vertex stage must declare and use '" + name + "' (see the standard uniform set in AGENTS.md)",
203
+ )
204
+ }
205
+ }
174
206
  let program: ProgramId | undefined
175
207
  let pipeline: RenderPipelineId | undefined
208
+ // Attributes live in the vertex stage only, so unlike the uNormal scan
209
+ // there is nothing to look for in the fragment source.
210
+ let layout: VertexLayout = /\baColor\b/.test(opts.vertex) ? "colored" : "standard"
176
211
  return {
212
+ normalMatrix: /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment),
213
+ layout,
177
214
  pipeline() {
178
215
  if (pipeline === undefined) {
179
216
  let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
@@ -182,7 +219,7 @@ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
182
219
  destroyShader(vs)
183
220
  destroyShader(fs)
184
221
  pipeline = createRenderPipeline(program, {
185
- attributes: VERTEX_LAYOUT,
222
+ attributes: VERTEX_LAYOUTS[layout],
186
223
  depth: opts.depth ?? true,
187
224
  depthWrite: opts.depthWrite,
188
225
  blend: opts.blend,
package/src/math.ts CHANGED
@@ -9,7 +9,11 @@
9
9
  // interpreter. Mat4 is a 16-tuple so constant-index access stays plain
10
10
  // `number` under noUncheckedIndexedAccess.
11
11
 
12
+ export type Vec2 = [number, number]
12
13
  export type Vec3 = [number, number, number]
14
+ export type Vec4 = [number, number, number, number]
15
+ /** A rotation as [x, y, z, w] - glTF's and Three's component order. */
16
+ export type Quat = [number, number, number, number]
13
17
  // prettier-ignore
14
18
  export type Mat4 = [
15
19
  number, number, number, number,
@@ -39,6 +43,20 @@ export function copy(out: Mat4, m: Mat4): Mat4 {
39
43
  return out
40
44
  }
41
45
 
46
+ /**
47
+ * Transform a point by m with w = 1, keeping the homogeneous result: the
48
+ * clip-space building block (scene.project, picking). The caller owns the
49
+ * perspective divide and the w <= 0 behind-the-camera test.
50
+ */
51
+ export function transformPoint(out: Vec4, m: Mat4, p: Vec3): Vec4 {
52
+ let x = p[0], y = p[1], z = p[2]
53
+ out[0] = m[0] * x + m[4] * y + m[8] * z + m[12]
54
+ out[1] = m[1] * x + m[5] * y + m[9] * z + m[13]
55
+ out[2] = m[2] * x + m[6] * y + m[10] * z + m[14]
56
+ out[3] = m[3] * x + m[7] * y + m[11] * z + m[15]
57
+ return out
58
+ }
59
+
42
60
  /** out = a * b (column vectors: b applies first). out may alias a or b. */
43
61
  export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
44
62
  let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]
@@ -69,30 +87,51 @@ export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
69
87
  }
70
88
 
71
89
  /**
72
- * Compose translation + rotation + scale into a local matrix. Rotation is
73
- * Euler angles in radians applied x, then y, then z (R = Rz * Ry * Rx on
74
- * column vectors) - the common "XYZ" order.
90
+ * Compose translation + rotation + scale into a local matrix - Three's
91
+ * `Matrix4.compose` signature, rotation as a quaternion.
92
+ *
93
+ * `rotation` must be a UNIT quaternion: a non-unit one scales the geometry
94
+ * by |q|^2, silently. The scene closes that trap by normalizing on write
95
+ * (setTransform) rather than paying for a check on every compose.
75
96
  */
76
- export function compose(out: Mat4, position: Vec3, rotation: Vec3, scale: Vec3): Mat4 {
77
- let cx = Math.cos(rotation[0]), sx = Math.sin(rotation[0])
78
- let cy = Math.cos(rotation[1]), sy = Math.sin(rotation[1])
79
- let cz = Math.cos(rotation[2]), sz = Math.sin(rotation[2])
80
- let r00 = cz * cy
81
- let r01 = cz * sy * sx - sz * cx
82
- let r02 = cz * sy * cx + sz * sx
83
- let r10 = sz * cy
84
- let r11 = sz * sy * sx + cz * cx
85
- let r12 = sz * sy * cx - cz * sx
86
- let r20 = -sy
87
- let r21 = cy * sx
88
- let r22 = cy * cx
89
- out[0] = r00 * scale[0]; out[1] = r10 * scale[0]; out[2] = r20 * scale[0]; out[3] = 0
90
- out[4] = r01 * scale[1]; out[5] = r11 * scale[1]; out[6] = r21 * scale[1]; out[7] = 0
91
- out[8] = r02 * scale[2]; out[9] = r12 * scale[2]; out[10] = r22 * scale[2]; out[11] = 0
97
+ export function compose(out: Mat4, position: Vec3, rotation: Quat, scale: Vec3): Mat4 {
98
+ let x = rotation[0], y = rotation[1], z = rotation[2], w = rotation[3]
99
+ let x2 = x + x, y2 = y + y, z2 = z + z
100
+ let xx = x * x2, xy = x * y2, xz = x * z2
101
+ let yy = y * y2, yz = y * z2, zz = z * z2
102
+ let wx = w * x2, wy = w * y2, wz = w * z2
103
+ let sx = scale[0], sy = scale[1], sz = scale[2]
104
+ out[0] = (1 - (yy + zz)) * sx; out[1] = (xy + wz) * sx; out[2] = (xz - wy) * sx; out[3] = 0
105
+ out[4] = (xy - wz) * sy; out[5] = (1 - (xx + zz)) * sy; out[6] = (yz + wx) * sy; out[7] = 0
106
+ out[8] = (xz + wy) * sz; out[9] = (yz - wx) * sz; out[10] = (1 - (xx + yy)) * sz; out[11] = 0
92
107
  out[12] = position[0]; out[13] = position[1]; out[14] = position[2]; out[15] = 1
93
108
  return out
94
109
  }
95
110
 
111
+ /**
112
+ * The normal matrix for a world matrix: the inverse-transpose of its upper
113
+ * 3x3 (the cofactor matrix over the determinant), packed into a mat4 - the
114
+ * engine's settable uniform set has no mat3, so shaders take `mat3(uNormal)`.
115
+ * Correct under any transform including non-uniform scale, where
116
+ * `mat3(uModel)` would bend normals off the surface. A degenerate
117
+ * (zero-scale) input yields the raw cofactors instead of NaNs.
118
+ */
119
+ export function normalMatrix(out: Mat4, m: Mat4): Mat4 {
120
+ let a = m[0], b = m[4], c = m[8]
121
+ let d = m[1], e = m[5], f = m[9]
122
+ let g = m[2], h = m[6], i = m[10]
123
+ let c00 = e * i - f * h
124
+ let c01 = f * g - d * i
125
+ let c02 = d * h - e * g
126
+ let det = a * c00 + b * c01 + c * c02
127
+ let s = 1 / (det || 1)
128
+ out[0] = c00 * s; out[1] = (c * h - b * i) * s; out[2] = (b * f - c * e) * s; out[3] = 0
129
+ out[4] = c01 * s; out[5] = (a * i - c * g) * s; out[6] = (c * d - a * f) * s; out[7] = 0
130
+ out[8] = c02 * s; out[9] = (b * g - a * h) * s; out[10] = (a * e - b * d) * s; out[11] = 0
131
+ out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1
132
+ return out
133
+ }
134
+
96
135
  /**
97
136
  * Right-handed perspective projection with the engine's y-down clip flip
98
137
  * BAKED IN (row two is negated): geometry authored y-up displays y-up, and
@@ -110,6 +149,278 @@ export function perspective(out: Mat4, fovy: number, aspect: number, near: numbe
110
149
  return out
111
150
  }
112
151
 
152
+ // Quaternions, the rotation the scene actually stores. Euler triples are a
153
+ // boundary format only - authoring (setTransform's `rotation`, the
154
+ // components' `rotation` prop) and reading back (getRotation) - so the order
155
+ // convention and gimbal lock live at that boundary and nowhere else.
156
+
157
+ /** A fresh identity rotation. */
158
+ export function quat(): Quat {
159
+ return [0, 0, 0, 1]
160
+ }
161
+
162
+ /** Unit quaternion; a zero-length input comes back as the identity. `out`
163
+ * may alias `q`. */
164
+ export function quatNormalize(out: Quat, q: Quat): Quat {
165
+ let len = Math.hypot(q[0], q[1], q[2], q[3])
166
+ if (len === 0) {
167
+ out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1
168
+ return out
169
+ }
170
+ out[0] = q[0] / len; out[1] = q[1] / len; out[2] = q[2] / len; out[3] = q[3] / len
171
+ return out
172
+ }
173
+
174
+ /**
175
+ * Euler radians to a quaternion, in XYZ order: x applied first, then y,
176
+ * then z (R = Rx * Ry * Rz on column vectors), Three's `Euler` default - a
177
+ * triple copied from a Three scene means the same thing here.
178
+ *
179
+ * ONE order exists, deliberately: a per-call order argument is how the same
180
+ * triple ends up meaning two different things in two places, and the
181
+ * quaternion is right there for anything an order was going to express.
182
+ */
183
+ export function quatFromEuler(out: Quat, euler: Vec3): Quat {
184
+ let c1 = Math.cos(euler[0] / 2), s1 = Math.sin(euler[0] / 2)
185
+ let c2 = Math.cos(euler[1] / 2), s2 = Math.sin(euler[1] / 2)
186
+ let c3 = Math.cos(euler[2] / 2), s3 = Math.sin(euler[2] / 2)
187
+ out[0] = s1 * c2 * c3 + c1 * s2 * s3
188
+ out[1] = c1 * s2 * c3 - s1 * c2 * s3
189
+ out[2] = c1 * c2 * s3 + s1 * s2 * c3
190
+ out[3] = c1 * c2 * c3 - s1 * s2 * s3
191
+ return out
192
+ }
193
+
194
+ /**
195
+ * A quaternion back to Euler radians in the same XYZ order - the inverse of
196
+ * quatFromEuler, a convenience for reading and debugging rather than a peer
197
+ * of the quaternion: the mapping is many-to-one (a triple and that triple
198
+ * plus a full turn agree), and at the poles (local +z straight up or down)
199
+ * only the sum of x and z is determined, so this pins z to 0 and folds the
200
+ * roll into x. Round-tripping the result reproduces the rotation exactly;
201
+ * it need not reproduce the triple you started from.
202
+ */
203
+ export function eulerFromQuat(out: Vec3, q: Quat): Vec3 {
204
+ let x = q[0], y = q[1], z = q[2], w = q[3]
205
+ let x2 = x + x, y2 = y + y, z2 = z + z
206
+ let xx = x * x2, xy = x * y2, xz = x * z2
207
+ let yy = y * y2, yz = y * z2, zz = z * z2
208
+ let wx = w * x2, wy = w * y2, wz = w * z2
209
+ // The same matrix entries compose() writes: m02 = sin(y) alone, and the
210
+ // x/z pair reads off the rest unless cos(y) is 0 (the pole).
211
+ let m00 = 1 - (yy + zz)
212
+ let m01 = xy - wz
213
+ let m02 = xz + wy
214
+ // cos(y), which both remaining pairs scale with. atan2 against it beats
215
+ // asin(m02) - Three's form - near the poles, where asin's derivative
216
+ // blows up and a 1e-16 error in m02 becomes 1e-8 in the angle. It also
217
+ // lets the pole branch start three orders of magnitude later: the pairs
218
+ // stay well-conditioned until cos(y) approaches the noise floor.
219
+ let cy = Math.hypot(m00, m01)
220
+ out[1] = Math.atan2(m02, cy)
221
+ if (cy > 1e-7) {
222
+ out[0] = Math.atan2(wx - yz, 1 - (xx + yy))
223
+ out[2] = Math.atan2(-m01, m00)
224
+ } else {
225
+ // Only x + z (at +y) or x - z (at -y) is determined; pin z and fold
226
+ // the whole roll into x.
227
+ out[0] = Math.atan2(yz + wx, 1 - (xx + zz))
228
+ out[2] = 0
229
+ }
230
+ return out
231
+ }
232
+
233
+ /**
234
+ * The rotation of `angle` RADIANS about `axis` - Three's `setFromAxisAngle`,
235
+ * Unity's `AngleAxis` (which takes degrees; this takes radians like
236
+ * everything else here). The axis need not be normalized - the named
237
+ * engines all require a unit axis and silently corrupt the rotation
238
+ * otherwise, the same precondition trap `quatFromTo` closes. A zero axis
239
+ * yields the identity.
240
+ */
241
+ export function quatFromAxisAngle(out: Quat, axis: Vec3, angle: number): Quat {
242
+ let x = axis[0], y = axis[1], z = axis[2]
243
+ let len = Math.hypot(x, y, z)
244
+ if (len === 0) {
245
+ out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1
246
+ return out
247
+ }
248
+ let s = Math.sin(angle / 2) / len
249
+ out[0] = x * s
250
+ out[1] = y * s
251
+ out[2] = z * s
252
+ out[3] = Math.cos(angle / 2)
253
+ return out
254
+ }
255
+
256
+ /**
257
+ * out = a * b - the same order contract as the mat4 `multiply` above: on
258
+ * column vectors b applies first, so `quatMultiply(q, spin, q)` composes a
259
+ * further world-frame spin onto q while `quatMultiply(q, q, spin)` spins
260
+ * about q's own local frame. `out` may alias `a` or `b`.
261
+ *
262
+ * The product of unit quaternions is unit up to float drift, so this does
263
+ * not renormalize; an accumulator composed every frame drifts slowly, and
264
+ * the scene's setTransform renormalizes on write anyway.
265
+ */
266
+ export function quatMultiply(out: Quat, a: Quat, b: Quat): Quat {
267
+ let ax = a[0], ay = a[1], az = a[2], aw = a[3]
268
+ let bx = b[0], by = b[1], bz = b[2], bw = b[3]
269
+ out[0] = aw * bx + ax * bw + ay * bz - az * by
270
+ out[1] = aw * by - ax * bz + ay * bw + az * bx
271
+ out[2] = aw * bz + ax * by - ay * bx + az * bw
272
+ out[3] = aw * bw - ax * bx - ay * by - az * bz
273
+ return out
274
+ }
275
+
276
+ /**
277
+ * Spherical interpolation from `a` to `b`: constant angular velocity along
278
+ * the shortest path (the sign of `b` is flipped when the pair straddles the
279
+ * quaternion double cover, so it never takes the long way round). t = 0 is
280
+ * `a`, t = 1 is `b`'s rotation; inputs must be unit and the result is unit.
281
+ * `out` may alias `a` or `b`.
282
+ *
283
+ * The canonical damped follow is
284
+ * `quatSlerp(q, q, target, 1 - Math.exp(-k * dt))` - frame-rate
285
+ * independent, k is the tracking speed.
286
+ */
287
+ export function quatSlerp(out: Quat, a: Quat, b: Quat, t: number): Quat {
288
+ let ax = a[0], ay = a[1], az = a[2], aw = a[3]
289
+ let bx = b[0], by = b[1], bz = b[2], bw = b[3]
290
+ let cos = ax * bx + ay * by + az * bz + aw * bw
291
+ if (cos < 0) {
292
+ cos = -cos
293
+ bx = -bx; by = -by; bz = -bz; bw = -bw
294
+ }
295
+ let wa: number
296
+ let wb: number
297
+ if (cos < 0.9995) {
298
+ let theta = Math.acos(cos > 1 ? 1 : cos)
299
+ let sin = Math.sin(theta)
300
+ wa = Math.sin((1 - t) * theta) / sin
301
+ wb = Math.sin(t * theta) / sin
302
+ } else {
303
+ // Nearly identical: sin(theta) is noise, and a straight lerp is within
304
+ // float precision of the arc - normalized below like any other result.
305
+ wa = 1 - t
306
+ wb = t
307
+ }
308
+ out[0] = wa * ax + wb * bx
309
+ out[1] = wa * ay + wb * by
310
+ out[2] = wa * az + wb * bz
311
+ out[3] = wa * aw + wb * bw
312
+ return quatNormalize(out, out)
313
+ }
314
+
315
+ /**
316
+ * The shortest-arc rotation taking `from` to `to`: Unity's
317
+ * `Quaternion.FromToRotation`, glam's `Quat::from_rotation_arc`. Three
318
+ * calls this `setFromUnitVectors`; renamed because that name states a
319
+ * precondition instead of the operation, and this one has no such
320
+ * precondition - neither input need be normalized.
321
+ *
322
+ * This is how a y-axis solid gets aimed - `quatFromTo(q, [0, 1, 0], dir)`
323
+ * for a cylinder or cone - where lookAt's +z convention would need a
324
+ * correction. Opposite vectors have no shortest arc (every half turn is
325
+ * equally short); a stable perpendicular axis is picked. A zero-length
326
+ * input yields the identity.
327
+ */
328
+ export function quatFromTo(out: Quat, from: Vec3, to: Vec3): Quat {
329
+ let ax = from[0], ay = from[1], az = from[2]
330
+ let bx = to[0], by = to[1], bz = to[2]
331
+ let la = Math.hypot(ax, ay, az)
332
+ let lb = Math.hypot(bx, by, bz)
333
+ if (la === 0 || lb === 0) {
334
+ out[0] = 0; out[1] = 0; out[2] = 0; out[3] = 1
335
+ return out
336
+ }
337
+ ax /= la; ay /= la; az /= la
338
+ bx /= lb; by /= lb; bz /= lb
339
+ let r = ax * bx + ay * by + az * bz + 1
340
+ if (r < 1e-6) {
341
+ // Antiparallel: the cross product vanishes, so take any perpendicular
342
+ // axis - crossing with the smaller of from's x/z components cannot
343
+ // vanish too, and picking off from alone keeps the choice stable.
344
+ r = 0
345
+ if (Math.abs(ax) > Math.abs(az)) {
346
+ out[0] = -ay; out[1] = ax; out[2] = 0
347
+ } else {
348
+ out[0] = 0; out[1] = -az; out[2] = ay
349
+ }
350
+ } else {
351
+ out[0] = ay * bz - az * by
352
+ out[1] = az * bx - ax * bz
353
+ out[2] = ax * by - ay * bx
354
+ }
355
+ out[3] = r
356
+ return quatNormalize(out, out)
357
+ }
358
+
359
+ /**
360
+ * The rotation that points the local +z axis along `forward`, with `up`
361
+ * choosing the roll about it - the object-aiming counterpart of lookAt(),
362
+ * which builds the camera's inverse frame. Neither input need be
363
+ * normalized. Degenerate inputs (zero forward, up parallel to forward) fall
364
+ * back to a stable perpendicular instead of producing NaNs.
365
+ */
366
+ export function quatFromFrame(out: Quat, forward: Vec3, up: Vec3): Quat {
367
+ let zx = forward[0], zy = forward[1], zz = forward[2]
368
+ let len = Math.hypot(zx, zy, zz)
369
+ if (len === 0) {
370
+ zx = 0; zy = 0; zz = 1
371
+ } else {
372
+ zx /= len; zy /= len; zz /= len
373
+ }
374
+ let xx = up[1] * zz - up[2] * zy
375
+ let xy = up[2] * zx - up[0] * zz
376
+ let xz = up[0] * zy - up[1] * zx
377
+ len = Math.hypot(xx, xy, xz)
378
+ if (len === 0) {
379
+ // up is parallel to forward: cross with a world axis that cannot be,
380
+ // picked off z's own components so the choice is stable per direction.
381
+ let ax = Math.abs(zx) < 0.9 ? 1 : 0
382
+ let ay = ax === 1 ? 0 : 1
383
+ xx = ay * zz
384
+ xy = -ax * zz
385
+ xz = ax * zy - ay * zx
386
+ len = Math.hypot(xx, xy, xz)
387
+ }
388
+ xx /= len; xy /= len; xz /= len
389
+ let yx = zy * xz - zz * xy
390
+ let yy = zz * xx - zx * xz
391
+ let yz = zx * xy - zy * xx
392
+ // X | Y | Z are the rotation's columns, so its diagonal is xx, yy, zz.
393
+ // Branching on the largest diagonal entry keeps the divisor away from
394
+ // zero; the basis is orthonormal, so the result is already unit.
395
+ let trace = xx + yy + zz
396
+ if (trace > 0) {
397
+ let s = 0.5 / Math.sqrt(trace + 1)
398
+ out[0] = (yz - zy) * s
399
+ out[1] = (zx - xz) * s
400
+ out[2] = (xy - yx) * s
401
+ out[3] = 0.25 / s
402
+ } else if (xx > yy && xx > zz) {
403
+ let s = 2 * Math.sqrt(1 + xx - yy - zz)
404
+ out[0] = 0.25 * s
405
+ out[1] = (yx + xy) / s
406
+ out[2] = (zx + xz) / s
407
+ out[3] = (yz - zy) / s
408
+ } else if (yy > zz) {
409
+ let s = 2 * Math.sqrt(1 + yy - xx - zz)
410
+ out[0] = (yx + xy) / s
411
+ out[1] = 0.25 * s
412
+ out[2] = (zy + yz) / s
413
+ out[3] = (zx - xz) / s
414
+ } else {
415
+ let s = 2 * Math.sqrt(1 + zz - xx - yy)
416
+ out[0] = (zx + xz) / s
417
+ out[1] = (zy + yz) / s
418
+ out[2] = 0.25 * s
419
+ out[3] = (xy - yx) / s
420
+ }
421
+ return out
422
+ }
423
+
113
424
  // Vec3 helpers for geometry construction. These allocate (unlike the matrix
114
425
  // functions above): they serve generation-time code - curve frames, normals -
115
426
  // not the per-frame path. Exposed on the /math subpath only, so `add` does
@@ -145,6 +456,10 @@ export function normalize(v: Vec3): Vec3 {
145
456
  * View matrix (world -> camera) for a camera at `eye` looking at `target`
146
457
  * with the given `up`. Degenerate inputs (eye == target, up parallel to the
147
458
  * view direction) fall back to axis defaults instead of producing NaNs.
459
+ *
460
+ * On the /math subpath ONLY - the package root's `lookAt` is the scene verb
461
+ * that aims a node (the Matrix4/Object3D split Three makes under the same
462
+ * name), the same collision rule the Vec3 helpers follow.
148
463
  */
149
464
  export function lookAt(out: Mat4, eye: Vec3, target: Vec3, up: Vec3): Mat4 {
150
465
  let zx = eye[0] - target[0]