lecodes-sdk 2.4.1 → 2.4.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.
Files changed (51) hide show
  1. package/dist/types/core/Aspect.d.ts +34 -5
  2. package/dist/types/g2/OneWay2D.d.ts +0 -1
  3. package/dist/types/g2/Physics2D.d.ts +8 -6
  4. package/dist/types/g2/Scene2D.d.ts +1 -1
  5. package/dist/types/g2/Shape2D.d.ts +43 -15
  6. package/dist/types/gl/Model.d.ts +3 -2
  7. package/dist/types/gl/Scene.d.ts +1 -1
  8. package/dist/types/gl/animation/DynamicBone.d.ts +99 -35
  9. package/dist/types/gl/animation/IK.d.ts +0 -1
  10. package/dist/types/gl/nav/NavAgent.d.ts +3 -2
  11. package/dist/types/gl/physics/Physics.d.ts +4 -5
  12. package/dist/types/gl/physics/Shape.d.ts +40 -15
  13. package/dist/types/gl/vehicle/Vehicle.d.ts +54 -35
  14. package/dist/types/gl/vehicle/Wheel.d.ts +31 -10
  15. package/dist/types/plugins/gen/ar/contract.d.ts +152 -0
  16. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +47 -21
  17. package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +17 -3
  18. package/dist/types/plugins/gen/audio-recorder/contract.d.ts +64 -0
  19. package/dist/types/plugins/gen/browser/contract.d.ts +108 -0
  20. package/dist/types/plugins/gen/camera/contract.d.ts +27 -0
  21. package/dist/types/plugins/gen/geolocation/contract.d.ts +52 -0
  22. package/dist/types/plugins/gen/map/contract.d.ts +144 -0
  23. package/dist/types/plugins/gen/push/contract.d.ts +83 -0
  24. package/dist/types/plugins/gen/qr-scanner/contract.d.ts +16 -0
  25. package/dist/types/plugins/gen/speech/contract.d.ts +67 -0
  26. package/dist/types/plugins/gen/webrtc/contract.d.ts +342 -0
  27. package/dist/types/version.d.ts +1 -1
  28. package/dist/types.json +1 -1
  29. package/package.json +1 -1
  30. package/src/bridges/gl.d.ts +5 -3
  31. package/src/compile/compileProject.ts +45 -10
  32. package/src/compile/index.ts +7 -0
  33. package/src/compile/webHalf.ts +255 -0
  34. package/src/core/Aspect.ts +118 -35
  35. package/src/g2/OneWay2D.ts +0 -2
  36. package/src/g2/Physics2D.ts +8 -16
  37. package/src/g2/Scene2D.ts +1 -1
  38. package/src/g2/Shape2D.ts +51 -22
  39. package/src/gl/Model.ts +3 -2
  40. package/src/gl/Scene.ts +1 -1
  41. package/src/gl/animation/DynamicBone.ts +549 -486
  42. package/src/gl/animation/IK.ts +0 -2
  43. package/src/gl/nav/NavAgent.ts +14 -12
  44. package/src/gl/physics/Physics.ts +4 -5
  45. package/src/gl/physics/Shape.ts +49 -23
  46. package/src/gl/vehicle/Vehicle.ts +102 -58
  47. package/src/gl/vehicle/Wheel.ts +45 -11
  48. package/src/plugins/gen/ar/contract.d.ts +35 -1
  49. package/src/plugins/gen/ar/sdk/ar.gen.ts +25 -6
  50. package/src/plugins/gen/ar/sdk/ar.ts +102 -35
  51. package/src/version.ts +1 -1
@@ -79,8 +79,6 @@ abstract class Chain<Name extends string> extends Aspect<Name, Node> {
79
79
  this._ik = 0
80
80
  this._rig = undefined
81
81
  }
82
- onReconfigure(): void { this.push() }
83
-
84
82
  protected params(): Float32Array {
85
83
  const p = new Float32Array(P.COUNT)
86
84
  p[P.WEIGHT] = this._weight
@@ -74,26 +74,26 @@ export class NavAgent extends Aspect<"agent", Node, NavAgentEvents> {
74
74
 
75
75
  /** Agent radius (metres). Defaults to the navmesh's. */
76
76
  get radius(): number { return this._radius }
77
- set radius(v: number) { this._radius = v; this._dirty = true }
77
+ set radius(v: number) { this._radius = v; this.dirty() }
78
78
  get height(): number { return this._height }
79
- set height(v: number) { this._height = v; this._dirty = true }
79
+ set height(v: number) { this._height = v; this.dirty() }
80
80
  /** Top speed (m/s). */
81
81
  get speed(): number { return this._speed }
82
- set speed(v: number) { this._speed = v; this._dirty = true }
82
+ set speed(v: number) { this._speed = v; this.dirty() }
83
83
  get acceleration(): number { return this._acceleration }
84
- set acceleration(v: number) { this._acceleration = v; this._dirty = true }
84
+ set acceleration(v: number) { this._acceleration = v; this.dirty() }
85
85
  /** Local (agent-vs-agent) avoidance quality. */
86
86
  get avoidance(): NavAgentAvoidance { return this._avoidance }
87
- set avoidance(v: NavAgentAvoidance) { this._avoidance = v; this._dirty = true }
87
+ set avoidance(v: NavAgentAvoidance) { this._avoidance = v; this.dirty() }
88
88
  /** Separation force weight (0 = none). */
89
89
  get separation(): number { return this._separation }
90
- set separation(v: number) { this._separation = v; this._dirty = true }
90
+ set separation(v: number) { this._separation = v; this.dirty() }
91
91
  /** Area names this agent may walk (default: all). */
92
92
  get include(): string[] | undefined { return this._include }
93
- set include(v: string[] | undefined) { this._include = v; this._dirty = true }
93
+ set include(v: string[] | undefined) { this._include = v; this.dirty() }
94
94
  /** Area names this agent never enters. */
95
95
  get exclude(): string[] | undefined { return this._exclude }
96
- set exclude(v: string[] | undefined) { this._exclude = v; this._dirty = true }
96
+ set exclude(v: string[] | undefined) { this._exclude = v; this.dirty() }
97
97
 
98
98
  state: NavAgentState = "idle"
99
99
  /** The current velocity after avoidance (m/s). */
@@ -111,7 +111,6 @@ export class NavAgent extends Aspect<"agent", Node, NavAgentEvents> {
111
111
  private _target: Vec3 | null = null
112
112
  /** velocity-driven (`move`) rather than target-driven */
113
113
  private _steering = false
114
- private _dirty = false
115
114
  private _faceX = 0
116
115
  private _faceZ = -1
117
116
  private _retryAt = 0
@@ -125,7 +124,12 @@ export class NavAgent extends Aspect<"agent", Node, NavAgentEvents> {
125
124
  this._join()
126
125
  }
127
126
 
128
- onReconfigure(): void { this._dirty = true }
127
+ /** A parameter changed: push the agent's params to the crowd (a no-op until it has a slot —
128
+ * `_join` sends them with the add). */
129
+ protected flush(): void {
130
+ const crowd = this._crowd
131
+ if (crowd && this._slot >= 0 && crowd._mesh === NavMesh.active) _creatorNav.crowdSetParams(crowd._id, this._slot, this.params())
132
+ }
129
133
 
130
134
  onDetach(): void {
131
135
  this._leave()
@@ -216,7 +220,6 @@ export class NavAgent extends Aspect<"agent", Node, NavAgentEvents> {
216
220
  if (slot < 0) { this._retryAt = crowd._step + JOIN_RETRY_STEPS; return false }
217
221
  this._crowd = crowd
218
222
  this._slot = slot
219
- this._dirty = false
220
223
  this._lastSeen = -1
221
224
  crowd._agents.add(this)
222
225
  if (this._target && !_creatorNav.crowdSetTarget(crowd._id, slot, this._target.x, this._target.y, this._target.z)) this._target = null
@@ -253,7 +256,6 @@ export class NavAgent extends Aspect<"agent", Node, NavAgentEvents> {
253
256
  }
254
257
  const crowd = this._crowd!
255
258
  if (crowd._mesh !== NavMesh.active) { this._leave(); return }
256
- if (this._dirty) { this._dirty = false; _creatorNav.crowdSetParams(crowd._id, this._slot, this.params()) }
257
259
  if (crowd._step === this._lastSeen) return // the crowd has not advanced since (a frame with no step)
258
260
  this._lastSeen = crowd._step
259
261
  const b = crowd._buf
@@ -72,11 +72,10 @@ export class Physics extends Aspect<"physics", Node> implements CompWriter {
72
72
  * `1`, ice `0.02`. A Vehicle's tires read the GROUND body's value, so this is the single number
73
73
  * that decides how well a car corners.
74
74
  *
75
- * Change it at runtime by re-configuring — `floor.aspect(Physics, { friction: 0.02 })` (an ice
76
- * patch) — or by assigning `floor.physics.friction = 0.02`; either way the setter pushes the new
77
- * surface to the live body at once. (chisel keeps an instance setter whose name appears as an
78
- * object-literal key anywhere in the bundle, so the config-object route survives tree-shaking
79
- * even when no code reads `.friction`.)
75
+ * Live: `floor.physics.friction = 0.02` (an ice patch) pushes the new surface to the body at
76
+ * once. (chisel keeps an instance setter whose name appears as an object-literal key anywhere in
77
+ * the bundle, so `aspect(Physics, { friction })` at build survives tree-shaking even when no code
78
+ * reads `.friction`.)
80
79
  */
81
80
  get friction(): number { return this._friction }
82
81
  set friction(v: number) {
@@ -18,6 +18,7 @@
18
18
  // (which follows the sim), rather than relying on the pick body.
19
19
 
20
20
  import { Aspect } from "../../core/Aspect"
21
+ import type { FieldMeta } from "../../core/fields"
21
22
  import { cx, cy, cz, type Vec3Like } from "../../math/vec"
22
23
  import type { Node } from "../Node"
23
24
 
@@ -51,23 +52,42 @@ const dim = (v: number): number => Math.max(MIN_HALF, r4(v))
51
52
  export class Shape extends Aspect<"shape", Node> {
52
53
  static readonly aspect = "shape"
53
54
 
55
+ // Every dimension is LIVE and applies AT ONCE: a write rebuilds the geometry in place, the body
56
+ // keeping its id, velocity and place (a crouch, a live editor edit) — synchronous, because the
57
+ // caller reads the result back right away (`controller.resizing`, the node's height). A write that
58
+ // leaves the geometry's signature unchanged does nothing. `fit()` writes every field and rebuilds once.
59
+ private _box?: Vec3Like
60
+ private _sphere?: number
61
+ private _cylinder?: { halfHeight: number, radius: number }
62
+ private _capsule?: { halfHeight: number, radius: number }
63
+ private _mesh?: boolean | "convex"
64
+ private _heightfield?: boolean
65
+ private _origin?: Vec3Like
66
+ private _raycast = true
67
+
54
68
  /** Box half-extents [hx, hy, hz] (world units). */
55
- box?: Vec3Like
69
+ get box(): Vec3Like | undefined { return this._box }
70
+ set box(v: Vec3Like | undefined) { this._box = v; this._changed() }
56
71
  /** Sphere radius. */
57
- sphere?: number
72
+ get sphere(): number | undefined { return this._sphere }
73
+ set sphere(v: number | undefined) { this._sphere = v; this._changed() }
58
74
  /** Cylinder aligned on Y: half-height + radius. */
59
- cylinder?: { halfHeight: number, radius: number }
75
+ get cylinder(): { halfHeight: number, radius: number } | undefined { return this._cylinder }
76
+ set cylinder(v: { halfHeight: number, radius: number } | undefined) { this._cylinder = v; this._changed() }
60
77
  /** Capsule aligned on Y: half-height of the cylinder section + radius. */
61
- capsule?: { halfHeight: number, radius: number }
78
+ get capsule(): { halfHeight: number, radius: number } | undefined { return this._capsule }
79
+ set capsule(v: { halfHeight: number, radius: number } | undefined) { this._capsule = v; this._changed() }
62
80
  /** The node's own triangles (a Mesh's geometry or a Model's GLB, world scale applied).
63
81
  * `true` = exact triangle mesh — static/kinematic bodies, triggers, characters' ground and picking
64
82
  * only (a dynamic body can't be a triangle mesh; Physics throws). `'convex'` = convex hull of the
65
83
  * vertices, works for dynamic bodies too. A Model uses its bind pose (skinned parts skipped). */
66
- mesh?: boolean | "convex"
84
+ get mesh(): boolean | "convex" | undefined { return this._mesh }
85
+ set mesh(v: boolean | "convex" | undefined) { this._mesh = v; this._changed() }
67
86
  /** The node's terrain (`Terrain.create(...).node`) as a Jolt height field — the exact drawn grid,
68
87
  * holes included (docs/terrain-plan.md). Static / kinematic / picking / character ground only,
69
88
  * like `mesh: true`. `terrain.commit()` pushes edits into the live shape. */
70
- heightfield?: boolean
89
+ get heightfield(): boolean | undefined { return this._heightfield }
90
+ set heightfield(v: boolean | undefined) { this._heightfield = v; this._changed() }
71
91
  /**
72
92
  * The shape's centre, relative to the node's origin — world units in the node's ROTATED frame
73
93
  * (the node's scale is ignored, exactly like the explicit dimensions above). Default [0, 0, 0].
@@ -81,15 +101,22 @@ export class Shape extends Aspect<"shape", Node> {
81
101
  * you would expect. With no `origin` an auto shape (`{}`) still centres itself on the node's own
82
102
  * mesh bounds — only explicit dimensions sit on the pivot by default.
83
103
  */
84
- origin?: Vec3Like
85
- /** Whether pointer rays can hit this shape (click / touchstart). Default true. */
86
- raycast = true
104
+ get origin(): Vec3Like | undefined { return this._origin }
105
+ set origin(v: Vec3Like | undefined) { this._origin = v; this._changed() }
106
+ /** Whether pointer rays can hit this shape (click / touchstart). Default true. Live, at once. */
107
+ get raycast(): boolean { return this._raycast }
108
+ set raycast(v: boolean) { this._raycast = v; if (this._bodyId) _creator.physicsSetPickable(this._bodyId, v) }
109
+
110
+ /** Scene-editor inspector: the geometry fields (accessors, so they are listed here by name). */
111
+ static fields: FieldMeta<Shape> = {
112
+ box: {}, sphere: {}, cylinder: {}, capsule: {}, mesh: {}, heightfield: {}, origin: {}, raycast: {},
113
+ }
87
114
 
88
115
  private _shapeId = 0
89
116
  private _bodyId = 0
90
117
  /** Centre the "auto" box measured for itself — used when `origin` isn't set explicitly. */
91
118
  private _autoOrigin: [number, number, number] | null = null
92
- /** Geometry props as of the last build (see onReconfigure). */
119
+ /** Geometry props as of the last build (see _changed). */
93
120
  private _sig = ""
94
121
 
95
122
  /** @internal native shape id (0 if the build has no physics). */
@@ -110,11 +137,10 @@ export class Shape extends Aspect<"shape", Node> {
110
137
  this.node._xfKind = 0
111
138
  }
112
139
 
113
- /** Re-configuring (`node.aspect(Shape, { origin })`) rebuilds the geometry when a dimension, the
114
- * kind or the origin changed, and re-applies pickability. The body keeps its id and velocity. */
115
- onReconfigure(): void {
116
- if (this._signature() !== this._sig) this._rebuild()
117
- if (this._bodyId) _creator.physicsSetPickable(this._bodyId, this.raycast)
140
+ /** A dimension, the kind or the origin was written: rebuild the geometry when it actually changed.
141
+ * The body keeps its id and velocity. */
142
+ private _changed(): void {
143
+ if (this._shapeId && this._signature() !== this._sig) this._rebuild()
118
144
  }
119
145
 
120
146
  /**
@@ -134,14 +160,14 @@ export class Shape extends Aspect<"shape", Node> {
134
160
  console.warn(`Shape.fit: "${this.node.name}" has no geometry to measure yet (a model still loading, or a node with no mesh)`)
135
161
  return this
136
162
  }
137
- this.box = f.box
138
- this.sphere = f.sphere
139
- this.cylinder = f.cylinder
140
- this.capsule = f.capsule
141
- if (kind === "mesh") this.mesh = true
142
- else if (kind === "convex") this.mesh = "convex"
143
- else if (kind !== undefined) this.mesh = undefined
144
- this.origin = f.origin
163
+ this._box = f.box
164
+ this._sphere = f.sphere
165
+ this._cylinder = f.cylinder
166
+ this._capsule = f.capsule
167
+ if (kind === "mesh") this._mesh = true
168
+ else if (kind === "convex") this._mesh = "convex"
169
+ else if (kind !== undefined) this._mesh = undefined
170
+ this._origin = f.origin
145
171
  this._rebuild()
146
172
  return this
147
173
  }
@@ -28,8 +28,8 @@
28
28
  // `Wheel` aspect of the wheel's own node (which is where Jolt keeps it too); Vehicle collects every
29
29
  // Wheel under itself and groups them into axles by Z.
30
30
  //
31
- // The engine gets NUMBERS: the blob of settings (`_buildSettings`, live re-tunable) and one input
32
- // vector per frame. It never shapes them — no taper, no rate limit, no gearbox, no idle governor.
31
+ // The engine gets NUMBERS: the blob of settings (`_buildSettings`: its tuning half re-sent from
32
+ // `flush()` whenever a live value of the car or a wheel is written) and one input vector per frame. It never shapes them — no taper, no rate limit, no gearbox, no idle governor.
33
33
  //
34
34
  // Chassis space is forward −Z / up +Y — the SDK's own convention, the one `node.forward` reports: if
35
35
  // the model faces the other way, rotate the MODEL (a child), never the chassis node. Physics owns the
@@ -175,6 +175,12 @@ export interface AeroConfig {
175
175
  drag?: number
176
176
  }
177
177
 
178
+ // The three groups' defaults — every key the live views carry (a key left out of an assigned object
179
+ // takes the value here). `curve` / `feedback` / `driver` default to "not given", as the blob reads them.
180
+ const ENGINE_DEFAULTS: EngineConfig = { torque: 500, maxRpm: 6000, idleRpm: 1000, curve: undefined, inertia: 0.5, braking: 0.2 }
181
+ const STEERING_DEFAULTS: SteeringConfig = { maxAngle: 35, caster: 0.03, trail: 0.025, trailAtPeak: 0.5, feedback: undefined, driver: undefined }
182
+ const AERO_DEFAULTS: AeroConfig = { downforce: 0, drag: 0.03 }
183
+
178
184
  /** Blob v15 — see bridges.d.ts for the field-by-field layout. */
179
185
  const BLOB_VERSION = 15
180
186
  const BLOB_HEADER = 34
@@ -199,21 +205,42 @@ export class Vehicle extends Aspect<"vehicle", Node> {
199
205
  * (`id` stays 0, with a warning). */
200
206
  static get supported(): boolean { return Physics.supported && typeof _creator.vehicleCreate === "function" }
201
207
 
202
- /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
203
- mass = 1200
208
+ // ---- the car as hardware. LIVE (a write reaches the engine before the next step — a drift button
209
+ // is `car.vehicle.differential = 'locked'`): differential, antiRoll, maxTilt, friction,
210
+ // clutchStrength, clutchCapacity, and the three groups engine / steering / aero (write a field, or
211
+ // assign a whole object: what it leaves out is the default). STRUCTURAL (baked when the car is
212
+ // built — build it again with `car.aspect(Vehicle, {…})`, which resets its motion): mass,
213
+ // centerOfMass, driveAxle, and which wheels exist, where, with what width and travel.
214
+
215
+ private _mass = 1200
216
+ /** Chassis mass in kg (default 1200). Wheels are massless in this model. Structural: a write on a
217
+ * live car warns and keeps the built mass. */
218
+ get mass(): number { return this._mass }
219
+ set mass(v: number) {
220
+ if (this._id && v !== this._builtMass) {
221
+ console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — build the aspect again to change it`)
222
+ return
223
+ }
224
+ this._mass = v
225
+ }
204
226
  /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to halfway
205
- * down the chassis shape — a car with its mass at the shape's centre tips over. */
227
+ * down the chassis shape — a car with its mass at the shape's centre tips over. Structural. */
206
228
  centerOfMass?: number
207
229
  /** Which axle the engine drives (default "rear") — the default for every wheel's own `driven`
208
- * share: 1 on that axle, 0 elsewhere. `none` = no drive at all (a trailer, a pushed cart). */
230
+ * share: 1 on that axle, 0 elsewhere. `none` = no drive at all (a trailer, a pushed cart). Structural. */
209
231
  driveAxle: DriveLayout = "rear"
232
+
233
+ private _differential: DifferentialMode | number = "lsd"
210
234
  /** Differential lock across each driven axle. `locked` is the drift setup — both wheels turn
211
- * together, so the pair lets go as a pair. Live: re-configure the aspect to switch it mid-drive. */
212
- differential: DifferentialMode | number = "lsd"
235
+ * together, so the pair lets go as a pair. Live: switch it mid-drive. */
236
+ get differential(): DifferentialMode | number { return this._differential }
237
+ set differential(v: DifferentialMode | number) { this._differential = v; this.dirty() }
238
+ private _antiRoll: boolean | number = true
213
239
  /**
214
240
  * Anti-roll bars between each axle's wheels — what stops a car rolling onto its inside wheels in a
215
241
  * fast corner. `true` (default) is a bar 0.01 as stiff as the wheel's own spring; a number is that
216
- * ratio directly, and `0` / `false` removes the bars.
242
+ * ratio directly, and `0` / `false` removes the bars. Live: the stiffness, on axles built with a
243
+ * bar (`false` at build = no bar to stiffen later).
217
244
  *
218
245
  * **The numbers are far smaller than they look and the gain is steep** — Jolt's bar feeds
219
246
  * `lengthDifference × stiffness × dt` as a bias into the suspension's own soft constraint, so 4%
@@ -223,25 +250,44 @@ export class Vehicle extends Aspect<"vehicle", Node> {
223
250
  * This is the knob for how much a car visibly LEANS, not for wheel lift: bars change how far the
224
251
  * body tips, not how much load transfers. For lift, lower `centerOfMass`.
225
252
  */
226
- antiRoll: boolean | number = true
227
- /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. */
228
- maxTilt = 60
253
+ get antiRoll(): boolean | number { return this._antiRoll }
254
+ set antiRoll(v: boolean | number) { this._antiRoll = v; this.dirty() }
255
+ private _maxTilt = 60
256
+ /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. Live. */
257
+ get maxTilt(): number { return this._maxTilt }
258
+ set maxTilt(v: number) { this._maxTilt = v; this.dirty() }
259
+ private _friction = DEFAULT_FRICTION
229
260
  /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side. Nothing
230
- * to do with the tires; those are the `Wheel`'s `tire` × the ground's friction. */
231
- friction = DEFAULT_FRICTION
261
+ * to do with the tires; those are the `Wheel`'s `tire` × the ground's friction. Live, at once. */
262
+ get friction(): number { return this._friction }
263
+ set friction(v: number) { this._friction = v; if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, v) }
264
+ private _clutchStrength = 10
232
265
  /** Strength of the clutch when fully engaged (default 10). Small values slip; large ones weld the
233
- * engine to the wheels. `clutch` (the input) scales it 0..1. */
234
- clutchStrength = 10
266
+ * engine to the wheels. `clutch` (the input) scales it 0..1. Live. */
267
+ get clutchStrength(): number { return this._clutchStrength }
268
+ set clutchStrength(v: number) { this._clutchStrength = v; this.dirty() }
269
+ private _clutchCapacity = 0
235
270
  /** The clutch's torque CAPACITY, N·m (default 0 = none: Jolt's viscous clutch alone, torque =
236
271
  * `clutchStrength` × slip speed, no ceiling — rigid at any real slip). Set it and the clutch
237
272
  * SLIPS at this much torque × `clutch` — a dry clutch, emulated a physics step at a time: a
238
273
  * launch can be feathered, and a dumped clutch in too high a gear drags the engine down instead
239
- * of lurching. A road car's is ~1.5× the engine's peak torque. */
240
- clutchCapacity = 0
241
-
242
- engine: EngineConfig = { torque: 500, maxRpm: 6000, idleRpm: 1000, inertia: 0.5, braking: 0.2 }
243
- steering: SteeringConfig = { maxAngle: 35 }
244
- aero: AeroConfig = { downforce: 0, drag: 0.03 }
274
+ * of lurching. A road car's is ~1.5× the engine's peak torque. Live. */
275
+ get clutchCapacity(): number { return this._clutchCapacity }
276
+ set clutchCapacity(v: number) { this._clutchCapacity = v; this.dirty() }
277
+
278
+ private _engine = this.live(ENGINE_DEFAULTS)
279
+ /** The engine (`EngineConfig`). Live: `car.vehicle.engine.torque = 800`, or assign a whole config. */
280
+ get engine(): EngineConfig { return this._engine }
281
+ set engine(v: EngineConfig) { this._engine = this.live(ENGINE_DEFAULTS, v); this.dirty() }
282
+ private _steering = this.live(STEERING_DEFAULTS)
283
+ /** The steering (`SteeringConfig`). Live, one level deep: `feedback` and `driver` are each
284
+ * replaced as a whole (`steering.driver = { maxTorque: 90 }`), not edited inside. */
285
+ get steering(): SteeringConfig { return this._steering }
286
+ set steering(v: SteeringConfig) { this._steering = this.live(STEERING_DEFAULTS, v); this.dirty() }
287
+ private _aero = this.live(AERO_DEFAULTS)
288
+ /** Aerodynamics (`AeroConfig`). Live: a wing that deploys is `car.vehicle.aero.downforce = 0.6`. */
289
+ get aero(): AeroConfig { return this._aero }
290
+ set aero(v: AeroConfig) { this._aero = this.live(AERO_DEFAULTS, v); this.dirty() }
245
291
 
246
292
  /** Scene-editor inspector: the flat, tunable half. The grouped objects are edited in code. */
247
293
  static fields: FieldMeta<Vehicle> = {
@@ -276,9 +322,33 @@ export class Vehicle extends Aspect<"vehicle", Node> {
276
322
  private _input = new Float32Array(INPUT_FIXED)
277
323
  private _sent = new Float32Array(INPUT_FIXED)
278
324
  private _dirty = true // an input write since the last send
279
- private _retunePending = false
325
+ private _rebuildPending = false
326
+
327
+ onAttach(): void { this._setup(false) }
328
+ onDetach(): void { this._teardown() }
329
+
330
+ /** A live value changed on the car or a wheel: the tuning half of the blob is re-sent in
331
+ * `flush()`, one send however many values changed this frame. */
332
+ protected flush(): void {
333
+ if (this._rebuildPending) {
334
+ // A wheel came or went: the constraint is built again around the wheels that are there now,
335
+ // where the car stands, its motion reset (the inputs are kept and re-sent).
336
+ this._rebuildPending = false
337
+ this._teardown()
338
+ this._setup(true)
339
+ return
340
+ }
341
+ if (this._id) _creator.vehicleSetTuning?.(this._id, this._buildSettings())
342
+ }
343
+
344
+ /** @internal A wheel's live value changed: retune with the next flush. */
345
+ _retune(): void { this.dirty() }
346
+ /** @internal A wheel was built or removed under a live car: rebuild with the next flush. */
347
+ _rebuildSoon(): void { if (this._id) { this._rebuildPending = true; this.dirty() } }
280
348
 
281
- onAttach(): void {
349
+ // `rebuild` = from flush(), after a wheel change: an author error that attach throws on is a
350
+ // warning here, and the car stays torn down until the next change.
351
+ private _setup(rebuild: boolean): void {
282
352
  if (!Physics.supported) return
283
353
  if (!_creator.vehicleCreate) {
284
354
  console.warn("Vehicle: this host has no vehicle model (Vehicle.supported is false) — the car will not move")
@@ -297,7 +367,9 @@ export class Vehicle extends Aspect<"vehicle", Node> {
297
367
 
298
368
  this._wheels = this._collectWheels()
299
369
  if (this._wheels.length === 0) {
300
- throw new Error("Vehicle found no wheels — put a Wheel aspect on each wheel node under the chassis: hubFL.aspect(Wheel)")
370
+ const msg = "Vehicle found no wheels — put a Wheel aspect on each wheel node under the chassis: hubFL.aspect(Wheel)"
371
+ if (rebuild) { console.warn(msg); return }
372
+ throw new Error(msg)
301
373
  }
302
374
 
303
375
  const shapeId = shape._claim() // the vehicle owns the shape; drop the pick-only body
@@ -324,7 +396,7 @@ export class Vehicle extends Aspect<"vehicle", Node> {
324
396
  this._dirty = true
325
397
  }
326
398
 
327
- onDetach(): void {
399
+ private _teardown(): void {
328
400
  if (this._id) { _creator.vehicleDestroy(this._id); this._id = 0; this._bodyId = 0 }
329
401
  for (const w of this._wheels) w._vehicle = null
330
402
  this._wheels = []
@@ -333,31 +405,6 @@ export class Vehicle extends Aspect<"vehicle", Node> {
333
405
  this.node.get(Shape)?._recreatePickBody() // hands the routing back to the pick body
334
406
  }
335
407
 
336
- /**
337
- * Re-configuring an attached vehicle retunes it in place — the way a drift button switches the
338
- * differential without the car so much as blinking:
339
- *
340
- * car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })
341
- *
342
- * Live: `differential`, `engine`, `steering`, `aero`, `maxTilt`, `friction`, `clutchStrength`,
343
- * `clutchCapacity`, `antiRoll` (its stiffness, on axles that have a bar), and every `Wheel`'s
344
- * `tire`. Everything else describes the car's STRUCTURE — `mass`, `centerOfMass`, which wheels
345
- * exist and where, their suspension, the driven shares — and is baked into the constraint at
346
- * attach; to change one of those, re-attach the aspect.
347
- */
348
- onReconfigure(): void {
349
- if (!this._id) return
350
- if (this.mass !== this._builtMass) {
351
- console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — re-add the aspect to change it`)
352
- this.mass = this._builtMass
353
- }
354
- this._retunePending = true
355
- if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
356
- }
357
-
358
- /** @internal A wheel's tire changed: re-send the tuning before the next step (one send however
359
- * many wheels changed this frame). */
360
- _retuneSoon(): void { this._retunePending = true }
361
408
 
362
409
  /** Native vehicle id (0 until attached / no physics support). */
363
410
  get id(): number { return this._id }
@@ -395,15 +442,12 @@ export class Vehicle extends Aspect<"vehicle", Node> {
395
442
  /**
396
443
  * EARLY phase: hand this frame's inputs to the engine, before the step. The vector is sent when
397
444
  * anything changed — and every frame while a throttle, steer or brake is non-zero, because that is
398
- * what keeps a driven car awake (an idle car with zero input sleeps). The pending re-tune (a
399
- * config or tire change) goes first, so the step runs on the new numbers.
445
+ * what keeps a driven car awake (an idle car with zero input sleeps). A pending re-tune (a live
446
+ * value of the car or a wheel changed) went out in `flush()`, before this phase, so the step runs
447
+ * on the new numbers.
400
448
  */
401
449
  protected updateBefore(): void {
402
450
  if (!this._id) return
403
- if (this._retunePending) {
404
- this._retunePending = false
405
- _creator.vehicleSetTuning?.(this._id, this._buildSettings())
406
- }
407
451
  const input = this._input
408
452
  input[0] = this._throttle
409
453
  input[1] = this._steer
@@ -567,7 +611,7 @@ export class Vehicle extends Aspect<"vehicle", Node> {
567
611
  const w = found[i]!
568
612
  const a = axleOf[i]!
569
613
  const measured = w._measure()
570
- w.radius ??= measured.radius
614
+ w._radius ??= measured.radius
571
615
  w.width ??= measured.width
572
616
  w.steer ??= a === 0
573
617
  w.driven ??= this.driveAxle === "none" ? 0 : this.driveAxle === "all" ? 1 : (this.driveAxle === "front" ? a === 0 : a === maxAxle) ? 1 : 0
@@ -138,13 +138,28 @@ export function resolveTire(tire: Tire): ResolvedTire {
138
138
  }
139
139
  }
140
140
 
141
+ /** `suspension`'s defaults — every key the live view carries. */
142
+ const SUSPENSION_DEFAULTS: Required<WheelSuspension> = { travel: 0.4, stiffness: 1.4, damping: 0.5 }
143
+
144
+ /**
145
+ * One wheel. LIVE: `radius`, `suspension.stiffness` / `damping` and `tire` — write them and the car
146
+ * retunes before the next step (a tire losing air: `wheel.radius *= 0.9`, `wheel.suspension.stiffness
147
+ * *= 0.7`, `wheel.tire = Tire.scale(wheel.tire, 0.5)`). STRUCTURAL: `width`, `suspension.travel`, `axle`,
148
+ * `steer`, `driven` — baked when the car is built; to change one, build the wheel again
149
+ * (`hub.aspect(Wheel, {…})` — the car rebuilds itself around it, its motion reset).
150
+ */
141
151
  export class Wheel extends Aspect<"wheel", Node> {
142
152
  static readonly aspect = "wheel"
143
153
 
154
+ /** @internal the radius as given, or as measured at bind (`radius` reads it). */
155
+ _radius?: number
144
156
  /** Wheel radius in metres. Default: measured from the node's rendered bounds, else 0.35. Too small
145
- * and the car scrapes, too large and it floats. */
146
- radius?: number
147
- /** Wheel width in metres. Default: measured, else 0.25. */
157
+ * and the car scrapes, too large and it floats. Live: the ROLLING radius — the suspension hangs
158
+ * where it was built, so a smaller one lowers that corner of the car, the way a tire losing air
159
+ * does; the wheel's inertia keeps the radius it was built with. */
160
+ get radius(): number | undefined { return this._radius }
161
+ set radius(v: number | undefined) { this._radius = v; this.dirty() }
162
+ /** Wheel width in metres. Default: measured, else 0.25. Structural. */
148
163
  width?: number
149
164
  /** Axle group, front to back. Default: derived from Z. Set it for a car whose wheels are not in neat
150
165
  * left/right pairs — an axle's wheels are paired for the differential and the anti-roll bar. */
@@ -157,10 +172,20 @@ export class Wheel extends Aspect<"wheel", Node> {
157
172
  * `0.2` on each rear. Structural: baked at attach. */
158
173
  driven?: number
159
174
 
160
- suspension: WheelSuspension = { travel: 0.4, stiffness: 1.4, damping: 0.5 }
161
- /** The tire (default `Tire.road()`). Live: re-assign it or re-configure the aspect and the car
162
- * retunes in place — a grip slider, a tire that heats up. */
163
- tire: Tire = Tire.road()
175
+ private _suspension = this.live(SUSPENSION_DEFAULTS)
176
+ /** The suspension. Live: `stiffness` and `damping` (a softer corner sags and rolls more) — write a
177
+ * field (`wheel.suspension.stiffness = 1`) or assign a whole object (what it leaves out is the
178
+ * default). `travel` is structural. */
179
+ get suspension(): WheelSuspension { return this._suspension }
180
+ set suspension(v: WheelSuspension) { this._suspension = this.live(SUSPENSION_DEFAULTS, v); this.dirty() }
181
+
182
+ private _tire = this.live(Tire.road())
183
+ /** The tire (default `Tire.road()`). Live: assign one (`wheel.tire = Tire.drift()`, or
184
+ * `Tire.scale(wheel.tire, 0.5)` for a grip slider, a tire that heats up) or write a field
185
+ * (`wheel.tire.traction = 1`); a curve is replaced, never edited in place. A tire assigned without
186
+ * `traction` / `circle` gets `road`'s. */
187
+ get tire(): Tire { return this._tire }
188
+ set tire(v: Tire) { this._tire = this.live(Tire.road(), v); this.dirty() }
164
189
 
165
190
  /** INPUT: brake torque on this wheel THIS frame, N·m (default 0). Sticky, like `Vehicle.throttle`:
166
191
  * write it every frame from a driver system's `updateBefore` — the foot brake's share, the
@@ -189,15 +214,24 @@ export class Wheel extends Aspect<"wheel", Node> {
189
214
  /** @internal Resting centre in chassis space (what the author placed). */
190
215
  _rest = new Vec3()
191
216
 
217
+ /** A wheel built under a car that already runs (`hub.aspect(Wheel, {…})` on a live car): the car
218
+ * rebuilds itself around the new set of wheels before the next step. */
219
+ onAttach(): void {
220
+ for (let n: Node | null = this.node.parent; n; n = n.parent) {
221
+ const v = (n as unknown as { vehicle?: Vehicle }).vehicle
222
+ if (v) { v._rebuildSoon(); return }
223
+ }
224
+ }
225
+
192
226
  onDetach(): void {
227
+ this._vehicle?._rebuildSoon() // one wheel fewer: the car is built again around what is left
193
228
  this._vehicle = null
194
229
  this._index = -1
195
230
  }
196
231
 
197
- /** Re-configuring a wheel on a live car retunes the car: the tire is live; size, suspension, the
198
- * driven share and the axle are structural (re-attach the Vehicle). */
199
- onReconfigure(): void {
200
- this._vehicle?._retuneSoon()
232
+ /** A live value changed (the tire, the radius, the spring): the car retunes in the same flush. */
233
+ protected flush(): void {
234
+ this._vehicle?._retune()
201
235
  }
202
236
 
203
237
  /** @internal Called by Vehicle once the car exists, with the geometry it resolved. */
@@ -5,6 +5,10 @@
5
5
  // camera and background the session drives every frame through the host's gl facade.
6
6
  // A session is ONE AR scene: ARScene opens it with the scene and its camera entity as params and
7
7
  // closes it with the scene. `@permission camera` on start: the host asks, the halves never do.
8
+ // What a session finds in the camera's picture and drives an entity to: an image target (a printed
9
+ // picture, by its bytes) and, since contract version 2, a QR code (a printed symbol, by its text) —
10
+ // both through the `image` event. Codes are the web's (WebXR) first: the native halves take them and
11
+ // say they track none yet.
8
12
  import type { Service, File, Entity, Scene3D, f64 } from "../../../plugin"
9
13
 
10
14
  export type ARMode = "world" | "images"
@@ -31,6 +35,20 @@ export interface ImageTarget {
31
35
  entity: Entity
32
36
  }
33
37
 
38
+ /** A printed QR code the session tracks: found by its text in the camera's picture; while
39
+ * tracked its entity is driven like an image target's — a rigid pose in metres, no scale — the
40
+ * symbol's centre, Y out of it, X along its top edge, Z towards its bottom. Web (WebXR) first;
41
+ * the native halves later. */
42
+ export interface QRTarget {
43
+ /** The code's text: a symbol with another text is not this target. "" = ANY code (the first
44
+ * one decoded): a test, or a plaque whose text is not known to the app. */
45
+ text: string
46
+ /** The printed side of the symbol (its modules, no quiet zone), metres. */
47
+ size: f64
48
+ /** The entity that follows the code (its Anchor node). */
49
+ entity: Entity
50
+ }
51
+
34
52
  export interface StartOptions {
35
53
  /** Default "world". "images": image tracking only (no planes, no hit test; iOS runs
36
54
  * ARImageTrackingConfiguration, which is lighter). */
@@ -38,6 +56,9 @@ export interface StartOptions {
38
56
  /** Default "horizontal". Ignored in "images". */
39
57
  planes?: PlaneDetection
40
58
  images?: ImageTarget[]
59
+ /** The QR codes to track (in "world" and in "images"); a half without them says so and drives
60
+ * none. @since 2 */
61
+ codes?: QRTarget[]
41
62
  /** Default "gravity". "gravityAndHeading": -Z points north (the compass). */
42
63
  worldAlignment?: WorldAlignment
43
64
  /** Deliver `light` events. Default false. */
@@ -76,7 +97,8 @@ export interface AREvents {
76
97
  /** An image target found, lost, or its size refined. While tracked its entity is driven: a rigid
77
98
  * pose in metres, no scale — the image's centre, Y out of it, the image in XZ (X along its width,
78
99
  * Z towards its bottom). `extent` = [width, height] metres, the framework's estimate (sent again
79
- * when it changes by a millimetre, at most 10 per second); the last one when lost. */
100
+ * when it changes by a millimetre, at most 10 per second); the last one when lost. A QR target's
101
+ * comes the same way (its frame: X along the symbol's top edge), `extent` = [size, size]. */
80
102
  image: { entity: Entity, tracked: boolean, extent: f64[] }
81
103
  /** An anchor made by `anchor()` lost or regained after a relocalization. */
82
104
  anchor: { entity: Entity, tracked: boolean }
@@ -86,6 +108,11 @@ export interface AREvents {
86
108
  /** The session failed and is over: "unavailable" (no ARKit / ARCore support, ARCore not
87
109
  * installed and declined), "camera" (the camera could not be used), "failed". */
88
110
  error: { code: ARErrorCode, message: string }
111
+ /** The session is over, and not by `stop()`: the user left it ("user" — the browser's back / exit
112
+ * of an immersive WebXR session) or the system took it ("system"). Not an error: ARScene closes
113
+ * the scene. ARKit and ARCore never end a session by themselves (an interruption is `tracking`
114
+ * limited): the native halves do not send it. @since 2 */
115
+ ended: { reason: "user" | "system" }
89
116
  }
90
117
 
91
118
  export interface AR extends Service<"ar", ARParams, AREvents> {
@@ -102,6 +129,13 @@ export interface AR extends Service<"ar", ARParams, AREvents> {
102
129
  * `StartOptions.images`.
103
130
  * @rejects notRunning failed */
104
131
  setImages(images: ImageTarget[]): Promise<void>
132
+ /** The twin of `setImages` for QR codes: replace the set of QR targets of a RUNNING session
133
+ * without restarting it; a code no longer in the set stops driving its entity (`tracked: false`
134
+ * once if it was tracked). Before `start`, the codes are `StartOptions.codes`. A half that tracks
135
+ * no codes yet (the native ones) rejects "failed".
136
+ * @since 2
137
+ * @rejects notRunning failed */
138
+ setCodes(codes: QRTarget[]): Promise<void>
105
139
  /** What is under a point of the scene's view — in its touches' space (a scene touch's `clientX` /
106
140
  * `clientY`: the units of the gl facade's viewport) — nearest first. Empty when nothing is, or on
107
141
  * a host without surfaces (the web).