lecodes-cli 0.17.0 → 0.17.2

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.
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { Color, type ColorInput } from "../core/color"
14
14
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
15
+ import type { Geometry } from "./Geometry"
15
16
  import { Material, _sheetColsRows, type ParticlesMaterialOptions } from "./Material"
16
17
  import { Node } from "./Node"
17
18
 
@@ -34,6 +35,11 @@ const TAG_SPACE = 12
34
35
  const TAG_INHERIT_VELOCITY = 13
35
36
  const TAG_RATE_DISTANCE = 14
36
37
  const TAG_RENDER_MODE = 15
38
+ const TAG_MESH_GEOMETRY = 16
39
+ const TAG_ANGULAR_VELOCITY = 17
40
+ const TAG_GROUND = 18
41
+ const TAG_VELOCITY_LIFE = 19
42
+ const TAG_ORDER = 20
37
43
 
38
44
  /** @internal — exported for the tag-sync test only. */
39
45
  export const _particleTags = {
@@ -41,7 +47,9 @@ export const _particleTags = {
41
47
  VELOCITY_RND: TAG_VELOCITY_RND, ACCELERATION: TAG_ACCELERATION, DRAG: TAG_DRAG,
42
48
  NOISE: TAG_NOISE, SEED: TAG_SEED, PARAM_CURVE: TAG_PARAM_CURVE, COLOR_CURVE: TAG_COLOR_CURVE,
43
49
  SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
44
- RENDER_MODE: TAG_RENDER_MODE,
50
+ RENDER_MODE: TAG_RENDER_MODE, MESH_GEOMETRY: TAG_MESH_GEOMETRY,
51
+ ANGULAR_VELOCITY: TAG_ANGULAR_VELOCITY, GROUND: TAG_GROUND, VELOCITY_LIFE: TAG_VELOCITY_LIFE,
52
+ ORDER: TAG_ORDER,
45
53
  }
46
54
 
47
55
  // --- curve builders -----------------------------------------------------------------------------
@@ -206,13 +214,51 @@ type VelocityValue = {
206
214
  randomizeAngle?: { min: Vec3Like, max: Vec3Like }
207
215
  } & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})
208
216
 
217
+ /** Where particles are born, in emitter space. Sphere / hemisphere / cone have an axis of +Y (the
218
+ * hemisphere is the +Y half, the cone opens along +Y from its base disc at `center`) — aim the
219
+ * node with `lookAt(target, 'y')`. `thickness` 0 emits from the surface only, 1 (default) from the
220
+ * whole volume. A sphere or cone also sets the LAUNCH DIRECTION (outward / from the cone's apex)
221
+ * when `startVelocity` gives a speed but no direction, like Unity's shapes. */
209
222
  type Shape =
210
223
  | { type: "point", v: Vec3Like }
211
224
  | { type: "box", min: Vec3Like, max: Vec3Like }
212
225
  | { type: "circle", center: Vec3Like, radius: number }
226
+ | { type: "sphere", center?: Vec3Like, radius: number, thickness?: number }
227
+ | { type: "hemisphere", center?: Vec3Like, radius: number, thickness?: number }
228
+ | { type: "cone", center?: Vec3Like, radius: number, /** half-angle, radians */ angle?: number, /** emit this far up the axis too */ length?: number, thickness?: number }
229
+ /** Extra velocity over each particle's life — evaluated from the curves every frame and ADDED as a
230
+ * displacement (not accumulated like `acceleration`): `radial` pushes outward along the particle's
231
+ * birth direction (a burst that decays: `curve(4).to(0)`), `x`/`y`/`z` drift in the simulation
232
+ * space (rising smoke: `y: curve(2).from(0).to(1)`). Unity's velocityOverLifetime. */
233
+ export type VelocityOverLife = { radial?: ParticleValue, x?: ParticleValue, y?: ParticleValue, z?: ParticleValue }
213
234
  type Noise = { strength?: number, frequency?: number, speed?: number }
214
235
 
236
+ /** Ground plane for bouncing particles (all render modes). `height` is in the simulation space:
237
+ * world y with `space: 'world'`, emitter-local y otherwise. */
238
+ export type ParticleGround = {
239
+ /** Plane height (default 0). */
240
+ height?: number
241
+ /** Contact radius — particles stop `radius` above the plane (a mesh's half-extent; default 0). */
242
+ radius?: number
243
+ /** Fraction of the vertical speed kept on impact, 0..1 (default 0.3). */
244
+ bounce?: number
245
+ /** Tangential damping while in contact, per second — like `drag` (default 8). */
246
+ friction?: number
247
+ }
248
+
215
249
  export type ParticlesOptions = ParticlesMaterialOptions & {
250
+ /** Draw every particle as this mesh — lit, tumbling 3D debris (shell casings, rubble, leaves,
251
+ * shrapnel) in one draw call. The system switches to the mesh render mode; the default
252
+ * material becomes `Material.lit()` (pass `material` for color/roughness/metallic — the
253
+ * sprite look options are ignored). `size` scales the mesh (default 1); `rotation`/`opacity`/
254
+ * `frame` don't apply. See `angularVelocity` and `ground`. */
255
+ mesh?: Geometry
256
+ /** (mesh) Spin, radians per second about the particle's own axes — a vector, or a per-component
257
+ * `{ min, max }` range randomized at spawn. Particles are born in the emitter's orientation. */
258
+ angularVelocity?: Vec3Like | { min: Vec3Like, max: Vec3Like }
259
+ /** A ground plane the particles bounce on and come to rest on (any render mode); `null`
260
+ * disables it. */
261
+ ground?: ParticleGround | null
216
262
  /** Custom draw material. Unset = the default point-sprite material, configured by the
217
263
  * `ParticlesMaterialOptions` sugar (map/sheet/emissive/blend/soft) — which is ignored
218
264
  * when an explicit material is passed. */
@@ -232,7 +278,14 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
232
278
  * spread evenly along the path — trail density independent of speed, no per-frame clumps.
233
279
  * Meant for `space: 'world'`. */
234
280
  rateOverDistance?: number
281
+ /** Draw order among blended systems at the same depth — higher draws later, i.e. on top (Unity's
282
+ * sortingOrder). Systems are depth-sorted by their node, so the emitters of one effect tie and
283
+ * without this the engine picks who covers whom per frame (smoke popping over a fireball).
284
+ * Default 0; a fireball wants 2, its smoke 1, a smoke trail -1. */
285
+ order?: number
235
286
  shape?: Shape
287
+ /** Extra velocity over life (radial burst that decays, axis drift) — see `VelocityOverLife`. */
288
+ velocityOverLife?: VelocityOverLife
236
289
  startVelocity?: Vec3Like | VelocityValue
237
290
  /** Downward acceleration, world units/s² — positive pulls particles DOWN (sugar for
238
291
  * `acceleration: [0, -g, 0]`). */
@@ -293,10 +346,54 @@ const pushColor = (out: number[], value: ParticleColor): void => {
293
346
  for (let i = 0; i < d.length; i++) out.push(d[i])
294
347
  }
295
348
 
349
+ const pushMesh = (out: number[], g: Geometry): void => {
350
+ const nv = g.vertices.length / 3
351
+ const ni = g.indices.length
352
+ out.push(TAG_MESH_GEOMETRY, 2 + nv * 8 + ni, nv, ni)
353
+ for (let i = 0; i < nv * 3; i++) out.push(g.vertices[i])
354
+ for (let i = 0; i < nv * 3; i++) out.push(g.normals[i])
355
+ for (let i = 0; i < nv * 2; i++) out.push(g.uv[i])
356
+ for (let i = 0; i < ni; i++) out.push(g.indices[i])
357
+ }
358
+
359
+ const pushAngularVelocity = (out: number[], v: Vec3Like | { min: Vec3Like, max: Vec3Like }): void => {
360
+ const r = v as { min: Vec3Like, max: Vec3Like }
361
+ if (typeof r === "object" && r !== null && !Array.isArray(r) && "min" in r && !(v instanceof Float32Array) && !(v instanceof Vec3)) {
362
+ out.push(TAG_ANGULAR_VELOCITY, 6, cx(r.min), cy(r.min), cz(r.min), cx(r.max), cy(r.max), cz(r.max))
363
+ } else {
364
+ const a = v as Vec3Like
365
+ out.push(TAG_ANGULAR_VELOCITY, 6, cx(a), cy(a), cz(a), cx(a), cy(a), cz(a))
366
+ }
367
+ }
368
+
369
+ const pushGround = (out: number[], g: ParticleGround | null): void => {
370
+ if (g === null) out.push(TAG_GROUND, 5, 0, 0, 0, 0, 0)
371
+ else out.push(TAG_GROUND, 5, 1, g.height ?? 0, g.radius ?? 0, g.bounce ?? 0.3, g.friction ?? 8)
372
+ }
373
+
296
374
  const pushShape = (out: number[], shape: Shape): void => {
297
375
  if (shape.type === "point") out.push(TAG_SHAPE, 4, 0, cx(shape.v), cy(shape.v), cz(shape.v))
298
376
  else if (shape.type === "box") out.push(TAG_SHAPE, 7, 1, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
299
- else out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
377
+ else if (shape.type === "circle") out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
378
+ else {
379
+ const c = shape.center ?? [ 0, 0, 0 ]
380
+ const thickness = shape.thickness ?? 1
381
+ if (shape.type === "cone") out.push(TAG_SHAPE, 8, 5, cx(c), cy(c), cz(c), shape.radius, shape.angle ?? 0, shape.length ?? 0, thickness)
382
+ else out.push(TAG_SHAPE, 6, shape.type === "sphere" ? 3 : 4, cx(c), cy(c), cz(c), shape.radius, thickness)
383
+ }
384
+ }
385
+
386
+ const pushVelocityLife = (out: number[], v: VelocityOverLife): void => {
387
+ const axes: Array<[number, ParticleValue | undefined]> = [ [ 0, v.radial ], [ 1, v.x ], [ 2, v.y ], [ 3, v.z ] ]
388
+ for (const [ axis, value ] of axes) {
389
+ if (value === undefined) continue
390
+ if (typeof value === "number") out.push(TAG_VELOCITY_LIFE, 5, axis, 0, value, value, 0)
391
+ else if (hasData(value)) {
392
+ const d = value._data
393
+ out.push(TAG_VELOCITY_LIFE, d.length + 1, axis)
394
+ for (let i = 0; i < d.length; i++) out.push(d[i])
395
+ } else out.push(TAG_VELOCITY_LIFE, 5, axis, 0, value.min, value.max, 0)
396
+ }
300
397
  }
301
398
 
302
399
  const pushVelocity = (out: number[], val: Vec3Like | VelocityValue): void => {
@@ -338,7 +435,7 @@ export class Particles extends Node {
338
435
 
339
436
  constructor(options: ParticlesOptions = {}) {
340
437
  super()
341
- this._material = options.material ?? Material.particles(options)
438
+ this._material = options.material ?? (options.mesh ? Material.lit() : Material.particles(options))
342
439
  _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
343
440
 
344
441
  const id = this.id
@@ -355,14 +452,21 @@ export class Particles extends Node {
355
452
  })
356
453
 
357
454
  const out: number[] = []
358
- if (options.render !== undefined && options.render !== "point") {
455
+ if (options.mesh) {
456
+ out.push(TAG_RENDER_MODE, 1, 3)
457
+ pushMesh(out, options.mesh)
458
+ } else if (options.render !== undefined && options.render !== "point") {
359
459
  out.push(TAG_RENDER_MODE, 1, options.render === "ribbon" ? 2 : 1)
360
460
  }
461
+ if (options.angularVelocity !== undefined) pushAngularVelocity(out, options.angularVelocity)
462
+ if (options.ground !== undefined) pushGround(out, options.ground)
361
463
  if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
362
464
  if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
363
465
  if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
466
+ if (options.order !== undefined) out.push(TAG_ORDER, 1, options.order)
364
467
  if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
365
468
  if (options.shape !== undefined) pushShape(out, options.shape)
469
+ if (options.velocityOverLife !== undefined) pushVelocityLife(out, options.velocityOverLife)
366
470
  if (options.startVelocity !== undefined) pushVelocity(out, options.startVelocity)
367
471
  if (options.acceleration !== undefined) {
368
472
  const a = options.acceleration
@@ -381,7 +485,7 @@ export class Particles extends Node {
381
485
  if (options.opacity !== undefined) pushParam(out, 2, options.opacity)
382
486
  if (options.frame !== undefined) {
383
487
  pushParam(out, 3, options.frame)
384
- } else if (options.material === undefined) {
488
+ } else if (options.material === undefined && !options.mesh) {
385
489
  const [ cols, rows ] = _sheetColsRows(options.sheet)
386
490
  const frames = cols * rows
387
491
  if (frames > 1) {
@@ -412,6 +516,13 @@ export class Particles extends Node {
412
516
  set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
413
517
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
414
518
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
519
+ set order(val: number) { this._send([ TAG_ORDER, 1, val ]) }
520
+
521
+ set velocityOverLife(v: VelocityOverLife) {
522
+ const out: number[] = []
523
+ pushVelocityLife(out, v)
524
+ this._send(out)
525
+ }
415
526
 
416
527
  set shape(shape: Shape) {
417
528
  const out: number[] = []
@@ -419,6 +530,25 @@ export class Particles extends Node {
419
530
  this._send(out)
420
531
  }
421
532
 
533
+ /** (mesh mode) Replace the particle mesh; live particles keep flying as the new shape. */
534
+ set mesh(g: Geometry) {
535
+ const out: number[] = [ TAG_RENDER_MODE, 1, 3 ]
536
+ pushMesh(out, g)
537
+ this._send(out)
538
+ }
539
+
540
+ set angularVelocity(v: Vec3Like | { min: Vec3Like, max: Vec3Like }) {
541
+ const out: number[] = []
542
+ pushAngularVelocity(out, v)
543
+ this._send(out)
544
+ }
545
+
546
+ set ground(g: ParticleGround | null) {
547
+ const out: number[] = []
548
+ pushGround(out, g)
549
+ this._send(out)
550
+ }
551
+
422
552
  set startVelocity(val: Vec3Like | VelocityValue) {
423
553
  const out: number[] = []
424
554
  pushVelocity(out, val)
@@ -1,119 +1,126 @@
1
- // Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
2
- // aspect on the same node (its geometry becomes the body's collision shape).
3
- //
4
- // Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
5
- // ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
6
- // crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
7
- // crate.physics.applyImpulse([0, 6, 0])
8
- // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
- //
10
- // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
11
- // velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic bodies
12
- // are driven with moveTo.
13
-
14
- import { Aspect } from "../core/Aspect"
15
- import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
16
- import { nodeRegistry, type Node } from "./Node"
17
- import { Shape } from "./Shape"
18
-
19
- export type MotionType = "static" | "dynamic" | "kinematic"
20
- const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
21
-
22
- export interface PhysicsConfig {
23
- /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
24
- gravity?: Vec3Like
25
- /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
26
- maxBodies?: number
27
- }
28
-
29
- /** Closest raycast hit. */
30
- export interface RayHit {
31
- node: Node | null
32
- point: Vec3
33
- normal: Vec3
34
- fraction: number
35
- }
36
-
37
- export class Physics extends Aspect<"physics", Node> {
38
- static readonly aspect = "physics"
39
-
40
- /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
41
- * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
42
- * smoothness — bodies then advance in discrete steps. */
43
- private static _interpolation = true
44
- static get interpolation(): boolean { return Physics._interpolation }
45
- static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
46
-
47
- /** Default "dynamic". */
48
- motion: MotionType = "dynamic"
49
- /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
50
- mass = 1
51
-
52
- private _bodyId = 0
53
-
54
- onAttach(): void {
55
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
56
- const shape = this.node.get(Shape)
57
- if (!shape) {
58
- throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
59
- }
60
- const shapeId = shape._claim() // drop the pick-only body; reuse its shape
61
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
62
- shape._ownBody(this._bodyId)
63
- }
64
-
65
- onDetach(): void {
66
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
67
- this.node.get(Shape)?._recreatePickBody() // keep the node pickable if the Shape stays
68
- }
69
-
70
- /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
71
- get id(): number { return this._bodyId }
72
-
73
- /** Linear velocity in world units/second (fresh Vec3 on read). */
74
- get velocity(): Vec3 {
75
- const out = new Float32Array(3)
76
- if (this._bodyId) _creator.physicsGetLinearVelocity(this._bodyId, out)
77
- return new Vec3(out[0], out[1], out[2])
78
- }
79
- set velocity(v: Vec3Like) {
80
- if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
81
- }
82
-
83
- /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
84
- applyImpulse(v: Vec3Like): this {
85
- if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
86
- return this
87
- }
88
-
89
- /** Teleport / drive a kinematic (or dynamic) body to a world position. */
90
- moveTo(p: Vec3Like): this {
91
- if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
92
- return this
93
- }
94
-
95
- // --- world (static) -------------------------------------------------------------------------
96
- /** Configure gravity / limits. Call once before creating bodies. */
97
- static configure(config: PhysicsConfig = {}): void {
98
- const g = config.gravity ?? [0, -9.81, 0]
99
- _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
100
- }
101
-
102
- /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
103
- static get supported(): boolean {
104
- return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
105
- }
106
-
107
- /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
108
- static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
109
- const out = new Float32Array(7)
110
- const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
111
- if (!id) return null
112
- return {
113
- node: nodeRegistry.get(id) ?? null,
114
- point: new Vec3(out[0], out[1], out[2]),
115
- normal: new Vec3(out[3], out[4], out[5]),
116
- fraction: out[6],
117
- }
118
- }
119
- }
1
+ // Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
2
+ // aspect on the same node (its geometry becomes the body's collision shape).
3
+ //
4
+ // Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
5
+ // ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
6
+ // crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
7
+ // crate.physics.applyImpulse([0, 6, 0])
8
+ // crate.addEventListener('enter', other => …) // contact / trigger overlap began
9
+ //
10
+ // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
11
+ // velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic bodies
12
+ // are driven with moveTo.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
16
+ import { nodeRegistry, type Node } from "./Node"
17
+ import { Shape } from "./Shape"
18
+
19
+ export type MotionType = "static" | "dynamic" | "kinematic"
20
+ const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
21
+
22
+ export interface PhysicsConfig {
23
+ /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
24
+ gravity?: Vec3Like
25
+ /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
26
+ maxBodies?: number
27
+ }
28
+
29
+ /** Closest raycast hit. */
30
+ export interface RayHit {
31
+ node: Node | null
32
+ point: Vec3
33
+ normal: Vec3
34
+ fraction: number
35
+ }
36
+
37
+ export class Physics extends Aspect<"physics", Node> {
38
+ static readonly aspect = "physics"
39
+
40
+ /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
41
+ * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
42
+ * smoothness — bodies then advance in discrete steps. */
43
+ private static _interpolation = true
44
+ static get interpolation(): boolean { return Physics._interpolation }
45
+ static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
46
+
47
+ /** Default "dynamic". */
48
+ motion: MotionType = "dynamic"
49
+ /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
50
+ mass = 1
51
+
52
+ private _bodyId = 0
53
+
54
+ onAttach(): void {
55
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
56
+ const shape = this.node.get(Shape)
57
+ if (!shape) {
58
+ throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
59
+ }
60
+ if (this.motion === "dynamic" && shape._isTriangleMesh) {
61
+ throw new Error("Physics: a dynamic body cannot use Shape { mesh: true } (triangle meshes are static/kinematic only) — use { mesh: 'convex' } or a primitive shape")
62
+ }
63
+ const shapeId = shape._claim() // drop the pick-only body; reuse its shape
64
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
65
+ shape._ownBody(this._bodyId)
66
+ }
67
+
68
+ onDetach(): void {
69
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
70
+ const shape = this.node.get(Shape)
71
+ if (shape) {
72
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
73
+ shape._recreatePickBody() // keep the node pickable if the Shape stays
74
+ }
75
+ }
76
+
77
+ /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
78
+ get id(): number { return this._bodyId }
79
+
80
+ /** Linear velocity in world units/second (fresh Vec3 on read). */
81
+ get velocity(): Vec3 {
82
+ const out = new Float32Array(3)
83
+ if (this._bodyId) _creator.physicsGetLinearVelocity(this._bodyId, out)
84
+ return new Vec3(out[0], out[1], out[2])
85
+ }
86
+ set velocity(v: Vec3Like) {
87
+ if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
88
+ }
89
+
90
+ /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
91
+ applyImpulse(v: Vec3Like): this {
92
+ if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
93
+ return this
94
+ }
95
+
96
+ /** Teleport / drive a kinematic (or dynamic) body to a world position. */
97
+ moveTo(p: Vec3Like): this {
98
+ if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
99
+ return this
100
+ }
101
+
102
+ // --- world (static) -------------------------------------------------------------------------
103
+ /** Configure gravity / limits. Call once before creating bodies. */
104
+ static configure(config: PhysicsConfig = {}): void {
105
+ const g = config.gravity ?? [0, -9.81, 0]
106
+ _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
107
+ }
108
+
109
+ /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
110
+ static get supported(): boolean {
111
+ return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
112
+ }
113
+
114
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
115
+ static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
116
+ const out = new Float32Array(7)
117
+ const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
118
+ if (!id) return null
119
+ return {
120
+ node: nodeRegistry.get(id) ?? null,
121
+ point: new Vec3(out[0], out[1], out[2]),
122
+ normal: new Vec3(out[3], out[4], out[5]),
123
+ fraction: out[6],
124
+ }
125
+ }
126
+ }