lecodes-cli 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.js +1340 -648
  2. package/package.json +3 -3
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/bundler.ts +1 -1
  5. package/runtime/sdk/compile/compileProject.ts +16 -1
  6. package/runtime/sdk/compile/header.ts +6 -1
  7. package/runtime/sdk/compile/index.ts +16 -0
  8. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  9. package/runtime/sdk/compile/shaderTargets.ts +42 -0
  10. package/runtime/sdk/core/Aspect.ts +255 -244
  11. package/runtime/sdk/g2/CharacterController2D.ts +2 -2
  12. package/runtime/sdk/gl/Camera.ts +41 -0
  13. package/runtime/sdk/gl/CameraPlace.ts +51 -0
  14. package/runtime/sdk/gl/CharacterController.ts +6 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +23 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Particles.ts +676 -625
  20. package/runtime/sdk/gl/Physics.ts +53 -1
  21. package/runtime/sdk/gl/Scene.ts +323 -291
  22. package/runtime/sdk/gl/Shape.ts +248 -8
  23. package/runtime/sdk/gl/Trigger.ts +50 -46
  24. package/runtime/sdk/gl/Vehicle.ts +519 -0
  25. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  26. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  27. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  28. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  29. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  30. package/runtime/sdk/gl/animation/core.ts +294 -0
  31. package/runtime/sdk/gl/scenarios.ts +26 -58
  32. package/runtime/sdk/inject.ts +18 -9
  33. package/runtime/sdk/runtime/app.ts +13 -0
  34. package/runtime/sdk/runtime/input.ts +169 -6
  35. package/runtime/sdk/scene/defineScene.ts +182 -26
  36. package/runtime/sdk/scene/gizmos.ts +128 -0
  37. package/runtime/sdk-types.json +1 -1
  38. package/runtime/sdk/gl/Animator.ts +0 -642
  39. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -8,15 +8,42 @@
8
8
  // wall.aspect(Shape, { box: [1, 2, 1] }).aspect(Physics, { motion: 'static' })
9
9
  // coin.aspect(Shape, { sphere: 0.5 }).aspect(Trigger)
10
10
  // crate.aspect(Shape, {}).aspect(Physics) // auto box from the mesh AABB × world scale
11
+ // barrel.aspect(Shape, { capsule: {…}, origin: [0, 0.9, 0] }) // collider lifted off the pivot
12
+ // tree.aspect(Shape, {}).shape.fit('capsule') // dimensions measured from the model
11
13
  //
12
14
  // Explicit dims are world-space half-extents (not scaled by the node). Use {} ("auto") to track the
13
- // mesh × world scale. Note: a Shape-only pick body is a STATIC snapshot at attach time — for a moving
14
- // pickable object add a Physics body (which follows the sim), rather than relying on the pick body.
15
+ // mesh × world scale. `origin` moves the shape off the node's pivot (world units, in the node's
16
+ // rotated frame) — the usual case is a model whose art sits above its origin. Note: a Shape-only
17
+ // pick body is a STATIC snapshot at attach time — for a moving pickable object add a Physics body
18
+ // (which follows the sim), rather than relying on the pick body.
15
19
 
16
20
  import { Aspect } from "../core/Aspect"
17
21
  import { cx, cy, cz, type Vec3Like } from "../math/vec"
18
22
  import type { Node } from "./Node"
19
23
 
24
+ /** Which geometry a Shape uses — the one-of the fields below encode (see `_build`'s order). */
25
+ export type ShapeKind = "auto" | "box" | "sphere" | "cylinder" | "capsule" | "mesh" | "convex"
26
+
27
+ /** A node-local AABB, in the node's OWN frame (its own transform excluded), before world scale. */
28
+ export type ShapeBounds = { min: [number, number, number], max: [number, number, number] }
29
+
30
+ /** Dimensions measured from a node's rendered geometry — what `Shape.fit` writes, and the numbers
31
+ * the scene editor's "Fit to mesh" button puts in the file. Exactly one dimension key is set (none
32
+ * for a mesh/convex kind, whose geometry is the mesh itself); `origin` centres it on the art. */
33
+ export type ShapeFit = {
34
+ box?: [number, number, number]
35
+ sphere?: number
36
+ cylinder?: { halfHeight: number, radius: number }
37
+ capsule?: { halfHeight: number, radius: number }
38
+ origin: [number, number, number]
39
+ }
40
+
41
+ /** Half-extents below this collapse a shape (Jolt refuses a box thinner than its convex radius,
42
+ * and a zero-radius sphere) — a fitted plane or a decal quad lands here. */
43
+ const MIN_HALF = 0.001
44
+ const r4 = (v: number): number => Math.round(v * 1e4) / 1e4
45
+ const dim = (v: number): number => Math.max(MIN_HALF, r4(v))
46
+
20
47
  export class Shape extends Aspect<"shape", Node> {
21
48
  static readonly aspect = "shape"
22
49
 
@@ -28,11 +55,34 @@ export class Shape extends Aspect<"shape", Node> {
28
55
  cylinder?: { halfHeight: number, radius: number }
29
56
  /** Capsule aligned on Y: half-height of the cylinder section + radius. */
30
57
  capsule?: { halfHeight: number, radius: number }
58
+ /** The node's own triangles (a Mesh's geometry or a Model's GLB, world scale applied).
59
+ * `true` = exact triangle mesh — static/kinematic bodies, triggers, characters' ground and picking
60
+ * only (a dynamic body can't be a triangle mesh; Physics throws). `'convex'` = convex hull of the
61
+ * vertices, works for dynamic bodies too. A Model uses its bind pose (skinned parts skipped). */
62
+ mesh?: boolean | "convex"
63
+ /**
64
+ * The shape's centre, relative to the node's origin — world units in the node's ROTATED frame
65
+ * (the node's scale is ignored, exactly like the explicit dimensions above). Default [0, 0, 0].
66
+ *
67
+ * A model's pivot is rarely its centre of volume (a character stands ON its origin, a wheel hangs
68
+ * off its axle), so a collider centred on the pivot is half sunk into the floor. `origin` lifts it
69
+ * without moving the node: `{ capsule: { halfHeight: 0.6, radius: 0.3 }, origin: [0, 0.9, 0] }`.
70
+ * `fit()` and the scene editor's "Fit to mesh" fill it in from the rendered bounds.
71
+ *
72
+ * On a DYNAMIC body the offset also moves the centre of mass, so a lopsided collider tips the way
73
+ * you would expect. With no `origin` an auto shape (`{}`) still centres itself on the node's own
74
+ * mesh bounds — only explicit dimensions sit on the pivot by default.
75
+ */
76
+ origin?: Vec3Like
31
77
  /** Whether pointer rays can hit this shape (click / touchstart). Default true. */
32
78
  raycast = true
33
79
 
34
80
  private _shapeId = 0
35
81
  private _bodyId = 0
82
+ /** Centre the "auto" box measured for itself — used when `origin` isn't set explicitly. */
83
+ private _autoOrigin: [number, number, number] | null = null
84
+ /** Geometry props as of the last build (see onReconfigure). */
85
+ private _sig = ""
36
86
 
37
87
  /** @internal native shape id (0 if the build has no physics). */
38
88
  get shapeId(): number { return this._shapeId }
@@ -50,6 +100,112 @@ export class Shape extends Aspect<"shape", Node> {
50
100
  if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
51
101
  }
52
102
 
103
+ /** Re-configuring (`node.aspect(Shape, { origin })`) rebuilds the geometry when a dimension, the
104
+ * kind or the origin changed, and re-applies pickability. The body keeps its id and velocity. */
105
+ onReconfigure(): void {
106
+ if (this._signature() !== this._sig) this._rebuild()
107
+ if (this._bodyId) _creator.physicsSetPickable(this._bodyId, this.raycast)
108
+ }
109
+
110
+ /**
111
+ * Measure the node's rendered geometry and size the collider to it — the code half of the scene
112
+ * editor's "Fit to mesh". Keeps the current kind unless one is passed; `auto` becomes an explicit
113
+ * box, and a mesh/convex shape only has its `origin` cleared (its geometry IS the mesh).
114
+ *
115
+ * crate.aspect(Shape, {}).shape.fit() // box around what the model actually draws
116
+ * hero.aspect(Shape, {}).shape.fit('capsule') // capsule of the same height + girth
117
+ *
118
+ * A model loads asynchronously, so fit AFTER its `load()` resolves — an empty subtree measures as
119
+ * nothing and the call is a no-op (it warns). Rebuilds the live shape, keeping the body.
120
+ */
121
+ fit(kind?: ShapeKind): this {
122
+ const f = Shape.fitTo(this.node, kind ?? this.kind)
123
+ if (!f) {
124
+ console.warn(`Shape.fit: "${this.node.name}" has no geometry to measure yet (a model still loading, or a node with no mesh)`)
125
+ return this
126
+ }
127
+ this.box = f.box
128
+ this.sphere = f.sphere
129
+ this.cylinder = f.cylinder
130
+ this.capsule = f.capsule
131
+ if (kind === "mesh") this.mesh = true
132
+ else if (kind === "convex") this.mesh = "convex"
133
+ else if (kind !== undefined) this.mesh = undefined
134
+ this.origin = f.origin
135
+ this._rebuild()
136
+ return this
137
+ }
138
+
139
+ /** Which geometry this shape currently uses (the `_build` dispatch, as a name). */
140
+ get kind(): ShapeKind {
141
+ if (this.mesh === "convex") return "convex"
142
+ if (this.mesh) return "mesh"
143
+ if (this.sphere !== undefined) return "sphere"
144
+ if (this.cylinder) return "cylinder"
145
+ if (this.capsule) return "capsule"
146
+ if (this.box) return "box"
147
+ return "auto"
148
+ }
149
+
150
+ // ---- fitting (static, so the scene-editor harness can measure without attaching) -------------
151
+
152
+ /**
153
+ * The node's subtree AABB in its OWN local frame (its own transform excluded), or null when there
154
+ * is nothing to measure. Reads the engine's bounds — which cover a loaded GLB and every child —
155
+ * and falls back to a Mesh node's own CPU geometry on hosts without them.
156
+ */
157
+ static boundsOf(node: Node): ShapeBounds | null {
158
+ const box = _creator.computeBoundingBox?.(node.id)
159
+ if (box && (box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) {
160
+ return { min: [box[0], box[1], box[2]], max: [box[3], box[4], box[5]] }
161
+ }
162
+ const v = node.geometry?.vertices
163
+ if (!v || v.length < 3) return null
164
+ const min: [number, number, number] = [Infinity, Infinity, Infinity]
165
+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
166
+ for (let i = 0; i + 2 < v.length; i += 3) {
167
+ for (let k = 0; k < 3; k++) {
168
+ if (v[i + k] < min[k]) min[k] = v[i + k]
169
+ if (v[i + k] > max[k]) max[k] = v[i + k]
170
+ }
171
+ }
172
+ return isFinite(min[0]) ? { min, max } : null
173
+ }
174
+
175
+ /**
176
+ * Dimensions + origin that make `kind` hug what `node` renders — world units, so the node's world
177
+ * scale is baked in exactly as the Shape fields expect. Null when the node has no geometry (a
178
+ * model that hasn't loaded, an empty).
179
+ */
180
+ static fitTo(node: Node, kind: ShapeKind = "box"): ShapeFit | null {
181
+ const b = Shape.boundsOf(node)
182
+ return b ? Shape.fitBounds(b, node.worldScale, kind) : null
183
+ }
184
+
185
+ /**
186
+ * The measuring itself, over plain numbers: a node-local AABB + the node's world scale in, shape
187
+ * dimensions out. Separate from `fitTo` so the scene-editor harness can measure a node from
188
+ * ANOTHER bundle without touching accessors the bundler may have shaken out of it.
189
+ *
190
+ * A sphere takes the LARGEST half-extent and a cylinder/capsule the larger of X/Z, so the fit
191
+ * reads as the object's silhouette rather than as an enclosing ball; adjust from there.
192
+ */
193
+ static fitBounds(bounds: ShapeBounds, scale: Vec3Like, kind: ShapeKind = "box"): ShapeFit {
194
+ const sc = [Math.abs(cx(scale)), Math.abs(cy(scale)), Math.abs(cz(scale))]
195
+ const half = [0, 1, 2].map((k) => (bounds.max[k] - bounds.min[k]) * 0.5 * sc[k])
196
+ const origin = [0, 1, 2].map((k) => r4((bounds.max[k] + bounds.min[k]) * 0.5 * sc[k])) as [number, number, number]
197
+ if (kind === "mesh" || kind === "convex") return { origin: [0, 0, 0] } // the mesh IS the geometry
198
+ if (kind === "sphere") return { sphere: dim(Math.max(half[0], half[1], half[2])), origin }
199
+ if (kind === "cylinder" || kind === "capsule") {
200
+ const radius = dim(Math.max(half[0], half[2]))
201
+ const halfHeight = kind === "capsule" ? Math.max(0, r4(half[1] - radius)) : dim(half[1])
202
+ return { [kind]: { halfHeight, radius }, origin }
203
+ }
204
+ return { box: [dim(half[0]), dim(half[1]), dim(half[2])], origin }
205
+ }
206
+
207
+ // ---- body/shape lifetime --------------------------------------------------------------------
208
+
53
209
  /** @internal Physics/Trigger take over: drop the pick body and hand back the shape id. */
54
210
  _claim(): number {
55
211
  if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
@@ -70,13 +226,94 @@ export class Shape extends Aspect<"shape", Node> {
70
226
  if (!this.raycast && this._bodyId) _creator.physicsSetPickable(this._bodyId, false)
71
227
  }
72
228
 
229
+ /** @internal true for an exact triangle mesh — Physics refuses a dynamic body on it. */
230
+ get _isTriangleMesh(): boolean { return this.mesh === true }
231
+
232
+ // Rebuild the geometry in place: the body keeps its id, velocity and world transform (Physics
233
+ // holds that id, so re-creating the body instead would strand it). A shape a CharacterController
234
+ // or a Vehicle claimed is theirs — they were handed the old one and keep it.
235
+ private _rebuild(): void {
236
+ if (!this._shapeId) return // never attached, or a build without physics
237
+ const previous = this._shapeId
238
+ const id = this._build()
239
+ if (!id) return // build failed — keep what works
240
+ this._shapeId = id
241
+ if (this._bodyId) {
242
+ if (_creator.physicsSetBodyShape) _creator.physicsSetBodyShape(this._bodyId, id, true)
243
+ else console.warn("Shape: this host can't re-shape a live body — the new dimensions apply from the next attach")
244
+ } else {
245
+ console.warn(`Shape: "${this.node.name}"'s geometry is owned by a CharacterController / Vehicle — re-attach it to pick up the new dimensions`)
246
+ }
247
+ _creator.physicsDestroyShape(previous)
248
+ }
249
+
73
250
  private _build(): number {
74
- if (this.sphere !== undefined) return _creator.physicsBuildSphere(this.sphere)
75
- if (this.cylinder) return _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
76
- if (this.capsule) return _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
77
- if (this.box) return _creator.physicsBuildBox(cx(this.box), cy(this.box), cz(this.box))
251
+ this._autoOrigin = null
252
+ this._sig = this._signature()
253
+ let id = 0
254
+ if (this.mesh) id = this._buildMesh(this.mesh === "convex")
255
+ else if (this.sphere !== undefined) id = _creator.physicsBuildSphere(this.sphere)
256
+ else if (this.cylinder) id = _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
257
+ else if (this.capsule) id = _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
258
+ else if (this.box) id = _creator.physicsBuildBox(cx(this.box), cy(this.box), cz(this.box))
259
+ else id = this._buildAutoBox()
260
+ if (id) this._applyOrigin(id)
261
+ return id
262
+ }
263
+
264
+ // Push `origin` (or, for an auto box, the mesh centre it measured) onto the built shape. A host
265
+ // without the call keeps the old "centred on the node" behaviour; only an EXPLICIT origin warns,
266
+ // since the auto centre is a silent improvement nobody asked for.
267
+ private _applyOrigin(shapeId: number): void {
268
+ const o = this.origin
269
+ const x = o ? cx(o) : this._autoOrigin?.[0] ?? 0
270
+ const y = o ? cy(o) : this._autoOrigin?.[1] ?? 0
271
+ const z = o ? cz(o) : this._autoOrigin?.[2] ?? 0
272
+ if (x === 0 && y === 0 && z === 0) return
273
+ if (!_creator.physicsSetShapeOrigin) {
274
+ if (o) console.warn("Shape { origin } needs a newer host — the collider stays centred on the node")
275
+ return
276
+ }
277
+ _creator.physicsSetShapeOrigin(shapeId, x, y, z)
278
+ }
78
279
 
79
- // auto: a box from the mesh AABB × world scale (so the physical box matches the rendered size).
280
+ // What a rebuild has to notice: the kind, its dimensions and the origin (`raycast` is applied to
281
+ // the body directly, and the node's own transform is the body's, not the shape's).
282
+ private _signature(): string {
283
+ const v = (a: Vec3Like | undefined) => (a ? `${cx(a)},${cy(a)},${cz(a)}` : "")
284
+ const t = (o: { halfHeight: number, radius: number } | undefined) => (o ? `${o.halfHeight},${o.radius}` : "")
285
+ return `${v(this.box)}|${this.sphere ?? ""}|${t(this.cylinder)}|${t(this.capsule)}|${this.mesh ?? ""}|${v(this.origin)}`
286
+ }
287
+
288
+ // mesh: a Mesh node hands its CPU geometry over; anything else is tried as a GLB root (the host
289
+ // reads the asset's triangles itself — 0 if the node is neither). Always × world scale: the
290
+ // triangles are node-local, so the collider must follow the rendered size.
291
+ private _buildMesh(convex: boolean): number {
292
+ if (!_creator.physicsBuildMesh) {
293
+ console.warn("Shape { mesh } needs a newer host — falling back to a box")
294
+ return this._buildAutoBox()
295
+ }
296
+ const s = this.node.worldScale
297
+ const geom = this.node.geometry
298
+ let id = 0
299
+ if (geom) {
300
+ if (geom._meshKind !== 0) throw new Error("Shape { mesh } needs a triangle geometry (not edges/vertices)")
301
+ id = _creator.physicsBuildMesh(geom.vertices, Uint32Array.from(geom.indices), convex, s.x, s.y, s.z)
302
+ } else {
303
+ id = _creator.physicsBuildMeshFromEntity(this.node.id, convex, s.x, s.y, s.z)
304
+ }
305
+ if (!id) {
306
+ console.warn(`Shape { mesh } could not build a ${convex ? "convex hull" : "triangle mesh"} for "${this.node.name}" (no triangles / not a Mesh or Model) — falling back to a box`)
307
+ return this._buildAutoBox()
308
+ }
309
+ return id
310
+ }
311
+
312
+ // auto: a box from the node's own mesh AABB × world scale (so the physical box matches the
313
+ // rendered size), CENTRED on those bounds — geometry modelled off its pivot gets a box that still
314
+ // wraps it. A node with no geometry of its own (a Model root, an empty) keeps the 0.5 default;
315
+ // `fit()` measures the whole subtree instead.
316
+ private _buildAutoBox(): number {
80
317
  const s = this.node.worldScale
81
318
  let hx = 0.5, hy = 0.5, hz = 0.5
82
319
  const geom = this.node.geometry
@@ -91,7 +328,10 @@ export class Shape extends Aspect<"shape", Node> {
91
328
  if (v[i + 2] < minZ) minZ = v[i + 2]
92
329
  if (v[i + 2] > maxZ) maxZ = v[i + 2]
93
330
  }
94
- hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
331
+ if (isFinite(minX)) {
332
+ hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
333
+ this._autoOrigin = [(maxX + minX) * 0.5 * s.x, (maxY + minY) * 0.5 * s.y, (maxZ + minZ) * 0.5 * s.z]
334
+ }
95
335
  }
96
336
  return _creator.physicsBuildBox(hx * s.x, hy * s.y, hz * s.z)
97
337
  }
@@ -1,46 +1,50 @@
1
- // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
- // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
- // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
- //
5
- // const goal = new Mesh(box(), material)
6
- // .aspect(Shape, { box: [1, 2, 1] })
7
- // .aspect(Trigger)
8
- // goal.addEventListener('enter', other => win(other))
9
- // goal.addEventListener('exit', other => …)
10
-
11
- import { Aspect } from "../core/Aspect"
12
- import { cx, cy, cz, type Vec3Like } from "../math/vec"
13
- import { Shape } from "./Shape"
14
- import { ensurePhysicsEvents } from "./physicsEvents"
15
- import type { Node } from "./Node"
16
-
17
- export class Trigger extends Aspect<"trigger", Node> {
18
- static readonly aspect = "trigger"
19
-
20
- private _bodyId = 0
21
- /** Native body id (0 if no physics support). */
22
- get id(): number { return this._bodyId }
23
-
24
- onAttach(): void {
25
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
26
- const shape = this.node.get(Shape)
27
- if (!shape) {
28
- throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
29
- }
30
- const shapeId = shape._claim()
31
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
32
- shape._ownBody(this._bodyId)
33
- ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
34
- }
35
-
36
- onDetach(): void {
37
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
38
- this.node.get(Shape)?._recreatePickBody()
39
- }
40
-
41
- /** Reposition the trigger zone to a world point. */
42
- moveTo(p: Vec3Like): this {
43
- if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
44
- return this
45
- }
46
- }
1
+ // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
+ // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
+ // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
+ //
5
+ // const goal = new Mesh(box(), material)
6
+ // .aspect(Shape, { box: [1, 2, 1] })
7
+ // .aspect(Trigger)
8
+ // goal.addEventListener('enter', other => win(other))
9
+ // goal.addEventListener('exit', other => …)
10
+
11
+ import { Aspect } from "../core/Aspect"
12
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
13
+ import { Shape } from "./Shape"
14
+ import { ensurePhysicsEvents } from "./physicsEvents"
15
+ import type { Node } from "./Node"
16
+
17
+ export class Trigger extends Aspect<"trigger", Node> {
18
+ static readonly aspect = "trigger"
19
+
20
+ private _bodyId = 0
21
+ /** Native body id (0 if no physics support). */
22
+ get id(): number { return this._bodyId }
23
+
24
+ onAttach(): void {
25
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
26
+ const shape = this.node.get(Shape)
27
+ if (!shape) {
28
+ throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
29
+ }
30
+ const shapeId = shape._claim()
31
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
32
+ shape._ownBody(this._bodyId)
33
+ ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
34
+ }
35
+
36
+ onDetach(): void {
37
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
38
+ const shape = this.node.get(Shape)
39
+ if (shape) {
40
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
41
+ shape._recreatePickBody()
42
+ }
43
+ }
44
+
45
+ /** Reposition the trigger zone to a world point. */
46
+ moveTo(p: Vec3Like): this {
47
+ if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
48
+ return this
49
+ }
50
+ }