lecodes-cli 0.17.2 → 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 (37) 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 +3 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +11 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Physics.ts +171 -126
  20. package/runtime/sdk/gl/Scene.ts +27 -3
  21. package/runtime/sdk/gl/Shape.ts +214 -10
  22. package/runtime/sdk/gl/Vehicle.ts +519 -0
  23. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  24. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  25. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  26. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  27. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  28. package/runtime/sdk/gl/animation/core.ts +294 -0
  29. package/runtime/sdk/gl/scenarios.ts +26 -58
  30. package/runtime/sdk/inject.ts +18 -9
  31. package/runtime/sdk/runtime/app.ts +13 -0
  32. package/runtime/sdk/runtime/input.ts +169 -6
  33. package/runtime/sdk/scene/defineScene.ts +182 -26
  34. package/runtime/sdk/scene/gizmos.ts +128 -0
  35. package/runtime/sdk-types.json +1 -1
  36. package/runtime/sdk/gl/Animator.ts +0 -642
  37. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -1,126 +1,171 @@
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
- }
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 type { FieldMeta } from "../core/fields"
16
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
17
+ import { nodeRegistry, type Node } from "./Node"
18
+ import { Shape } from "./Shape"
19
+
20
+ export type MotionType = "static" | "dynamic" | "kinematic"
21
+ const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
22
+
23
+ export interface PhysicsConfig {
24
+ /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
25
+ gravity?: Vec3Like
26
+ /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
27
+ maxBodies?: number
28
+ }
29
+
30
+ /** Closest raycast hit. */
31
+ export interface RayHit {
32
+ node: Node | null
33
+ point: Vec3
34
+ normal: Vec3
35
+ fraction: number
36
+ }
37
+
38
+ export class Physics extends Aspect<"physics", Node> {
39
+ static readonly aspect = "physics"
40
+
41
+ /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
42
+ * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
43
+ * smoothness — bodies then advance in discrete steps. */
44
+ private static _interpolation = true
45
+ static get interpolation(): boolean { return Physics._interpolation }
46
+ static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
47
+
48
+ /** Default "dynamic". */
49
+ motion: MotionType = "dynamic"
50
+ /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
51
+ mass = 1
52
+ /**
53
+ * Surface friction: 0 = ice, ~1 = grippy asphalt, and values above 1 are allowed (rubber on
54
+ * tarmac).
55
+ *
56
+ * The engine COMBINES the two touching bodies as `sqrt(a * b)`, so the LOWER value dominates and
57
+ * the floor caps everything standing on it. The default **0.6** is a neutral solid surface (the
58
+ * raw Jolt default of 0.2 is ice by game standards); dry tarmac a car should corner on wants a
59
+ * `1`, ice `0.02`. A Vehicle's tires read the GROUND body's value, so this is the single number
60
+ * that decides how well a car corners.
61
+ *
62
+ * Change it at runtime by re-configuring — `floor.aspect(Physics, { friction: 0.02 })` (an ice
63
+ * patch). It is a PLAIN FIELD on purpose: an accessor pair would be tree-shaken out of the
64
+ * bundle, because passing `{ friction }` in a config object is not a member reference the
65
+ * bundler can see, and the write would then silently land on a dead property.
66
+ */
67
+ friction = 0.6
68
+
69
+ /** Scene-editor inspector: `motion` as a dropdown (a string default alone infers a text box). */
70
+ static fields: FieldMeta<Physics> = {
71
+ motion: { options: [ "static", "kinematic", "dynamic" ] },
72
+ mass: { min: 0, step: 0.1 },
73
+ friction: { min: 0, max: 2, step: 0.05 },
74
+ }
75
+
76
+ private _bodyId = 0
77
+
78
+ onAttach(): void {
79
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
80
+ const shape = this.node.get(Shape)
81
+ if (!shape) {
82
+ throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
83
+ }
84
+ if (this.motion === "dynamic" && shape._isTriangleMesh) {
85
+ 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")
86
+ }
87
+ const shapeId = shape._claim() // drop the pick-only body; reuse its shape
88
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
89
+ this._applyFriction()
90
+ shape._ownBody(this._bodyId)
91
+ }
92
+
93
+ onDetach(): void {
94
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
95
+ const shape = this.node.get(Shape)
96
+ if (shape) {
97
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
98
+ shape._recreatePickBody() // keep the node pickable if the Shape stays
99
+ }
100
+ }
101
+
102
+ /** Re-configuring (`node.aspect(Physics, { friction })`) pushes the new surface to the live body. */
103
+ onReconfigure(): void { this._applyFriction() }
104
+
105
+ private _applyFriction(): void {
106
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
107
+ }
108
+
109
+ /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
110
+ get id(): number { return this._bodyId }
111
+
112
+ /** Linear velocity in world units/second (fresh Vec3 on read). */
113
+ get velocity(): Vec3 {
114
+ const out = new Float32Array(3)
115
+ if (this._bodyId) _creator.physicsGetLinearVelocity(this._bodyId, out)
116
+ return new Vec3(out[0], out[1], out[2])
117
+ }
118
+ set velocity(v: Vec3Like) {
119
+ if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
120
+ }
121
+
122
+ /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
123
+ applyImpulse(v: Vec3Like): this {
124
+ if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
125
+ return this
126
+ }
127
+
128
+ /** The same impulse applied at a WORLD-SPACE point instead of the centre of mass: the lever arm
129
+ * becomes angular impulse, so the body spins as well as moves (a bullet hitting a crate off
130
+ * centre, a blast lifting it by its base). Falls back to the central impulse on an older host. */
131
+ applyImpulseAt(v: Vec3Like, point: Vec3Like): this {
132
+ if (!this._bodyId) return this
133
+ if (_creator.physicsApplyImpulseAt) {
134
+ _creator.physicsApplyImpulseAt(this._bodyId, cx(v), cy(v), cz(v), cx(point), cy(point), cz(point))
135
+ } else {
136
+ _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
137
+ }
138
+ return this
139
+ }
140
+
141
+ /** Teleport / drive a kinematic (or dynamic) body to a world position. */
142
+ moveTo(p: Vec3Like): this {
143
+ if (this._bodyId) _creator.physicsSetBodyPosition(this._bodyId, cx(p), cy(p), cz(p))
144
+ return this
145
+ }
146
+
147
+ // --- world (static) -------------------------------------------------------------------------
148
+ /** Configure gravity / limits. Call once before creating bodies. */
149
+ static configure(config: PhysicsConfig = {}): void {
150
+ const g = config.gravity ?? [0, -9.81, 0]
151
+ _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
152
+ }
153
+
154
+ /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
155
+ static get supported(): boolean {
156
+ return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
157
+ }
158
+
159
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
160
+ static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
161
+ const out = new Float32Array(7)
162
+ const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
163
+ if (!id) return null
164
+ return {
165
+ node: nodeRegistry.get(id) ?? null,
166
+ point: new Vec3(out[0], out[1], out[2]),
167
+ normal: new Vec3(out[3], out[4], out[5]),
168
+ fraction: out[6],
169
+ }
170
+ }
171
+ }
@@ -33,8 +33,18 @@ export type SceneOptions = {
33
33
  toneMapping?: "aces" | "neutral" | "linear" | "filmic"
34
34
  /** Skybox / clear color. */
35
35
  skybox?: ColorInput
36
- /** Multisample anti-aliasing. */
37
- antialias?: boolean
36
+ /** Multisample anti-aliasing: `true` = 4×, `2`/`4` = that many samples, `false` = off (FXAA
37
+ * takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
38
+ * fill-rate cost after resolution — turn it down on big screens before anything else. */
39
+ antialias?: boolean | 2 | 4
40
+ /** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
41
+ * resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
42
+ * barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
43
+ renderScale?: number
44
+ /** Let the engine shrink the 3D buffers under `renderScale` only when frames run over budget
45
+ * (Filament dynamic resolution, sharpened upscale) down to `min` (default 0.5). Off by default:
46
+ * it makes the output frame-time dependent, so flow tests / headless renders never enable it. */
47
+ dynamicResolution?: boolean | { min?: number }
38
48
  }
39
49
 
40
50
  // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
@@ -92,7 +102,21 @@ export class Scene implements Presentable {
92
102
  _creator.setToneMapping?.(this._id, { neutral: 1, linear: 2, filmic: 3 }[options.toneMapping] ?? 0)
93
103
  }
94
104
  if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
95
- if (options.antialias) _creator.setSceneMultiSampleAntiAliasing(this._id, true, 4)
105
+ if (options.antialias !== undefined) {
106
+ const a = options.antialias
107
+ // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
108
+ _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
109
+ }
110
+ if (options.renderScale !== undefined || options.dynamicResolution) {
111
+ this.setRenderOptions(options.renderScale ?? 1, options.dynamicResolution ?? false)
112
+ }
113
+ }
114
+
115
+ /** Runtime form of `renderScale` / `dynamicResolution` (a graphics-settings menu). `renderScale`
116
+ * 0.25–1; older hosts without the method ignore it. */
117
+ setRenderOptions(renderScale: number, dynamicResolution: boolean | { min?: number } = false): void {
118
+ const min = typeof dynamicResolution === "object" ? (dynamicResolution.min ?? 0.5) : 0.5
119
+ _creator.setSceneRenderOptions?.(this._id, renderScale, !!dynamicResolution, min)
96
120
  }
97
121
 
98
122
  /** Scene occlusion material. */
@@ -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
 
@@ -33,11 +60,29 @@ export class Shape extends Aspect<"shape", Node> {
33
60
  * only (a dynamic body can't be a triangle mesh; Physics throws). `'convex'` = convex hull of the
34
61
  * vertices, works for dynamic bodies too. A Model uses its bind pose (skinned parts skipped). */
35
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
36
77
  /** Whether pointer rays can hit this shape (click / touchstart). Default true. */
37
78
  raycast = true
38
79
 
39
80
  private _shapeId = 0
40
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 = ""
41
86
 
42
87
  /** @internal native shape id (0 if the build has no physics). */
43
88
  get shapeId(): number { return this._shapeId }
@@ -55,6 +100,112 @@ export class Shape extends Aspect<"shape", Node> {
55
100
  if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
56
101
  }
57
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
+
58
209
  /** @internal Physics/Trigger take over: drop the pick body and hand back the shape id. */
59
210
  _claim(): number {
60
211
  if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
@@ -78,13 +229,60 @@ export class Shape extends Aspect<"shape", Node> {
78
229
  /** @internal true for an exact triangle mesh — Physics refuses a dynamic body on it. */
79
230
  get _isTriangleMesh(): boolean { return this.mesh === true }
80
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
+
81
250
  private _build(): number {
82
- if (this.mesh) return this._buildMesh(this.mesh === "convex")
83
- if (this.sphere !== undefined) return _creator.physicsBuildSphere(this.sphere)
84
- if (this.cylinder) return _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
85
- if (this.capsule) return _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
86
- if (this.box) return _creator.physicsBuildBox(cx(this.box), cy(this.box), cz(this.box))
87
- return this._buildAutoBox()
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
+ }
279
+
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)}`
88
286
  }
89
287
 
90
288
  // mesh: a Mesh node hands its CPU geometry over; anything else is tried as a GLB root (the host
@@ -111,7 +309,10 @@ export class Shape extends Aspect<"shape", Node> {
111
309
  return id
112
310
  }
113
311
 
114
- // auto: a box from the mesh AABB × world scale (so the physical box matches the rendered size).
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.
115
316
  private _buildAutoBox(): number {
116
317
  const s = this.node.worldScale
117
318
  let hx = 0.5, hy = 0.5, hz = 0.5
@@ -127,7 +328,10 @@ export class Shape extends Aspect<"shape", Node> {
127
328
  if (v[i + 2] < minZ) minZ = v[i + 2]
128
329
  if (v[i + 2] > maxZ) maxZ = v[i + 2]
129
330
  }
130
- 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
+ }
131
335
  }
132
336
  return _creator.physicsBuildBox(hx * s.x, hy * s.y, hz * s.z)
133
337
  }