lecodes-cli 0.18.0 → 0.18.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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2013 -564
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/core/Aspect.ts +512 -255
  6. package/runtime/sdk/core/compWrite.ts +42 -0
  7. package/runtime/sdk/core/fields.ts +1 -1
  8. package/runtime/sdk/core/time.ts +81 -0
  9. package/runtime/sdk/g2/Camera2D.ts +8 -1
  10. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  11. package/runtime/sdk/g2/Node2D.ts +80 -10
  12. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  13. package/runtime/sdk/g2/Physics2D.ts +240 -30
  14. package/runtime/sdk/g2/Scene2D.ts +33 -1
  15. package/runtime/sdk/g2/Shape2D.ts +218 -22
  16. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  17. package/runtime/sdk/g2/groups2d.ts +106 -0
  18. package/runtime/sdk/g2/loop.ts +15 -4
  19. package/runtime/sdk/gl/Camera.ts +40 -1
  20. package/runtime/sdk/gl/CameraPlace.ts +52 -51
  21. package/runtime/sdk/gl/CharacterController.ts +184 -56
  22. package/runtime/sdk/gl/Gearbox.ts +212 -0
  23. package/runtime/sdk/gl/Geometry.ts +70 -9
  24. package/runtime/sdk/gl/Light.ts +64 -2
  25. package/runtime/sdk/gl/Lightmap.ts +179 -0
  26. package/runtime/sdk/gl/Material.ts +25 -0
  27. package/runtime/sdk/gl/Mesh.ts +6 -23
  28. package/runtime/sdk/gl/Model.ts +16 -2
  29. package/runtime/sdk/gl/Node.ts +119 -39
  30. package/runtime/sdk/gl/Physics.ts +75 -24
  31. package/runtime/sdk/gl/Scene.ts +161 -5
  32. package/runtime/sdk/gl/Shape.ts +42 -3
  33. package/runtime/sdk/gl/Trigger.ts +45 -50
  34. package/runtime/sdk/gl/Vehicle.ts +276 -322
  35. package/runtime/sdk/gl/Wheel.ts +240 -0
  36. package/runtime/sdk/gl/scenarios.ts +4 -30
  37. package/runtime/sdk/gl/state.ts +6 -6
  38. package/runtime/sdk/inject.ts +186 -171
  39. package/runtime/sdk/runtime/device.ts +102 -1
  40. package/runtime/sdk/scene/defineScene.ts +108 -53
  41. package/runtime/sdk/scene/gizmos.ts +31 -11
  42. package/runtime/sdk/scene/material.ts +188 -0
  43. package/runtime/sdk-types.json +1 -1
  44. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  45. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  46. package/runtime/sdk/compile/assetMacro.ts +0 -45
  47. package/runtime/sdk/compile/assetName.ts +0 -50
  48. package/runtime/sdk/compile/bundler.ts +0 -252
  49. package/runtime/sdk/compile/compileProject.ts +0 -129
  50. package/runtime/sdk/compile/detectEntry.ts +0 -128
  51. package/runtime/sdk/compile/fontMacro.ts +0 -459
  52. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  53. package/runtime/sdk/compile/header.ts +0 -67
  54. package/runtime/sdk/compile/index.ts +0 -85
  55. package/runtime/sdk/compile/libraryImports.ts +0 -52
  56. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  57. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  58. package/runtime/sdk/compile/serverSplit.ts +0 -233
  59. package/runtime/sdk/compile/serverTypes.ts +0 -227
  60. package/runtime/sdk/compile/sfnt.ts +0 -98
  61. package/runtime/sdk/compile/shaderTargets.ts +0 -42
  62. package/runtime/sdk/compile/sourcemap.ts +0 -25
@@ -2,28 +2,28 @@
2
2
  // node is the CHASSIS: it needs a Shape (its collision body) and must NOT also carry a Physics aspect
3
3
  // — Vehicle owns its rigid body, the way CharacterController owns its controller.
4
4
  //
5
- // const car = new Node("car").add(bodyModel, wheelFL, wheelFR, wheelRL, wheelRR)
5
+ // const car = new Node('car')
6
+ // .add(body, wheelFL.aspect(Wheel), wheelFR.aspect(Wheel),
7
+ // wheelRL.aspect(Wheel), wheelRR.aspect(Wheel))
6
8
  // .aspect(Shape, { box: [0.9, 0.4, 2.1] })
7
- // .aspect(Vehicle, { mass: 1200, wheels: [
8
- // { node: wheelFL }, { node: wheelFR }, { node: wheelRL }, { node: wheelRR },
9
- // ] })
9
+ // .aspect(Vehicle, { mass: 1200 })
10
10
  //
11
11
  // setLoop(() => {
12
- // const gas = (Input.key("KeyW") ? 1 : 0) - (Input.key("KeyS") ? 1 : 0)
13
- // car.vehicle.drive(gas, (Input.key("KeyD") ? 1 : 0) - (Input.key("KeyA") ? 1 : 0), 0, Input.key("Space") ? 1 : 0)
14
- // hud.text = `${Math.round(car.vehicle.speed * 3.6)} km/h gear ${car.vehicle.gear}`
12
+ // const gas = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
13
+ // car.vehicle.drive(gas, (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0), 0, Input.key('Space') ? 1 : 0)
14
+ // hud.text = `${Math.round(car.vehicle.speed * 3.6)} km/h gear ${car.gearbox.gear}`
15
15
  // })
16
16
  //
17
- // That is the whole minimum: every wheel property is derived from where the wheel NODE sits. Wheels
18
- // are grouped into axles by their Z, the FRONT axle steers, the rear one drives and holds the
19
- // handbrake — override any of it per wheel (`steer`, `powered`, `handbrake`) or per vehicle
20
- // (`powered: "all"`). Chassis space is forward −Z / up +Y — the SDK's own convention, the one
21
- // `node.forward` reports: if the model faces the other way, rotate the MODEL (a child), never the
22
- // chassis node.
17
+ // This aspect is the car as a WHOLE: mass, engine, steering, aero. Anything per-wheel lives on the
18
+ // `Wheel` aspect of the wheel's own node (which is where Jolt keeps it too), and the gearbox is the
19
+ // `Gearbox` aspect — policy, not physics. Vehicle collects every Wheel under itself, groups them into
20
+ // axles by Z, and installs a default Gearbox if the node has none.
23
21
  //
24
- // Physics OWNS the chassis transform while a vehicle is attached — drive it with `drive()`, don't set
25
- // node.position. Each wheel node's LOCAL transform is overwritten every step (suspension travel, steer
26
- // angle, spin), so a wheel model that needs a corrective rotation must wear it on a child node.
22
+ // Chassis space is forward −Z / up +Y — the SDK's own convention, the one `node.forward` reports: if
23
+ // the model faces the other way, rotate the MODEL (a child), never the chassis node. Physics owns the
24
+ // chassis transform: drive it with `drive()`, never per-frame `node.position` writes.
25
+ //
26
+ // Layering, and why it is where it is: docs/vehicle-rework-plan.md.
27
27
 
28
28
  import { Aspect } from "../core/Aspect"
29
29
  import type { FieldMeta } from "../core/fields"
@@ -32,204 +32,161 @@ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
32
32
  import { cw, type QuatLike } from "../math/quat"
33
33
  import { Gizmos } from "../scene/gizmos"
34
34
  import { glState } from "./state"
35
+ import { Gearbox } from "./Gearbox"
35
36
  import { Node } from "./Node"
36
- import { Physics } from "./Physics"
37
+ import { DEFAULT_FRICTION, Physics } from "./Physics"
37
38
  import { Shape } from "./Shape"
39
+ import { STATE_HEADER, WHEEL_STATE, Wheel } from "./Wheel"
38
40
 
39
- /** Which axle the engine drives. Per-wheel `powered` overrides it. */
41
+ /** Which axle the engine drives. A wheel's own `drive` overrides it. */
40
42
  export type DriveLayout = "rear" | "front" | "all"
41
43
 
42
- export interface WheelConfig {
43
- /** The node posed by this wheel — a child of the chassis, or its name (looked up under the
44
- * chassis). Optional: a wheel can be physics-only (no visual). */
45
- node?: Node | string
46
- /** Where the wheel CENTRE sits when the car rests on flat ground, in chassis space. Defaults to
47
- * the wheel node's own position, so a placed model needs nothing here. */
48
- position?: Vec3Like
49
- /** Wheel radius in metres (default 0.35). Set it to match the model — too small and the car
50
- * scrapes, too large and it floats. */
51
- radius?: number
52
- /** Wheel width in metres (default 0.25). */
53
- width?: number
54
- /** Does this wheel steer? Default: the wheels on the front axle. */
55
- steer?: boolean
56
- /** Does the engine drive this wheel? Default: from the vehicle's `powered` layout. */
57
- powered?: boolean
58
- /** Foot brake on this wheel (default true). */
59
- brake?: boolean
60
- /** Handbrake on this wheel. Default: every non-steering wheel (locking the front wheels of a
61
- * moving car is not a handbrake turn, it's a crash). */
62
- handbrake?: boolean
63
- /** Axle group, front to back. Derived from Z when omitted; set it for a car whose wheels are not
64
- * in neat left/right pairs. Pairs an axle's wheels for the differential and the anti-roll bar. */
65
- axle?: number
66
- }
44
+ /**
45
+ * How an axle's two wheels share torque.
46
+ * - `lsd` — limited slip (Jolt's 1.4 ratio), the sane default.
47
+ * - `open` — fully open: a lifted or spinning wheel takes ALL the torque.
48
+ * - `locked` — both wheels forced to the same speed. Breaks traction predictably: the drift diff.
49
+ * A number is the raw max/min wheel-speed ratio (> 1; smaller is more locked).
50
+ */
51
+ export type DifferentialMode = "lsd" | "open" | "locked"
67
52
 
68
- export interface SuspensionConfig {
69
- /** How far the wheel can travel, in metres (default 0.3). */
70
- travel?: number
71
- /** Spring frequency in Hz — bigger is stiffer (default 1.5; a race car is ~2.5, a truck ~1). */
72
- stiffness?: number
73
- /** Damping ratio, 0..1 (default 0.5). Low values pogo. */
74
- damping?: number
53
+ export interface EngineConfig {
54
+ /** Peak torque in N·m (default 500). The single biggest "how fast is it" knob. */
55
+ torque?: number
56
+ /** Redline in RPM (default 6000). */
57
+ maxRpm?: number
58
+ /** Idle RPM (default 1000) — the engine never falls below it. */
59
+ idleRpm?: number
60
+ /**
61
+ * The engine's CHARACTER: a flat list `[rpm, torque, rpm, torque, …]` in NORMALISED units — x is
62
+ * a fraction of the redline, y a fraction of `torque`. Left out, Jolt's own broad curve is used.
63
+ *
64
+ * `[0, 0.8, 0.66, 1, 1, 0.8]` is an ordinary petrol engine (peak two thirds up, falling to the
65
+ * redline); `[0, 1, 1, 1]` is flat, which is what an electric motor feels like.
66
+ */
67
+ curve?: number[]
68
+ /** Moment of inertia in kg·m² (default 0.5) — how eagerly it revs when the clutch is out. */
69
+ inertia?: number
70
+ /** Engine braking: angular damping of the engine itself (default 0.2). */
71
+ braking?: number
75
72
  }
76
73
 
77
74
  export interface SteeringConfig {
78
- /** The lock still available, in degrees, once the car reaches `speed` (default 10). `0` turns the
79
- * taper off entirely — full lock at any speed. */
75
+ /** Steering lock in degrees at a standstill (default 35). */
76
+ lock?: number
77
+ /**
78
+ * The lock still available, in degrees, once the car reaches `speed` (default 10) — what stops a
79
+ * car over-driving its own front tires.
80
+ *
81
+ * A tire makes its peak grip at a few degrees of slip angle, and the steering angle a corner needs
82
+ * falls with the SQUARE of speed. Holding full lock at 100 km/h therefore asks the front for
83
+ * several times what it can give: the tires saturate, the car ploughs on, and the wheel stops
84
+ * answering. In between the lock eases on `(1 − v/speed)²`, which tracks the real curve closely.
85
+ * `0` turns the taper off — full lock at any speed.
86
+ */
80
87
  atSpeed?: number
81
88
  /** The speed in m/s at which the lock has fallen to `atSpeed` (default 30 ≈ 108 km/h). */
82
89
  speed?: number
83
- /** How fast the front wheels may turn, in degrees per second (default 220). `0` = instantly. */
90
+ /** How fast the front wheels may turn, in degrees per second (default 220). A keyboard asks for
91
+ * full lock in one frame, which no steering wheel can do, and that step alone snaps a tire past
92
+ * its peak. `0` = instantly. */
84
93
  rate?: number
85
94
  }
86
95
 
87
- export interface EngineConfig {
88
- /** Peak engine torque in N·m (default 500). The single biggest "how fast is it" knob. */
89
- torque?: number
90
- /** Redline in RPM (default 6000). */
91
- maxRpm?: number
92
- }
93
-
94
- /** One wheel's live state (`vehicle.wheel(i)`) — the same numbers Jolt's own vehicle debug overlay
95
- * shows. `gripLateral` saturating while `slipAngle` climbs is what "it will not turn at speed"
96
- * looks like in data. */
97
- export interface WheelState {
98
- /** Touching the ground this step. */
99
- contact: boolean
100
- /** Longitudinal slip RATIO: 0 = rolling in sync with the ground, ~1 = spinning up or locked.
101
- * Unsigned — it does not say whether the tire is driving or braking. */
102
- slip: number
103
- /** Slip ANGLE in degrees: how far the tire's travel direction is off from where it points.
104
- * Unsigned, 0..90. Past the friction curve's peak (~3°) the tire is sliding sideways. */
105
- slipAngle: number
106
- /** Longitudinal friction coefficient actually applied this step (curve value × ground friction). */
107
- gripLong: number
108
- /** Lateral friction coefficient actually applied this step — the cornering budget. */
109
- gripLateral: number
110
- /** Current suspension length in metres (short = compressed). */
111
- suspension: number
112
- /** Steer angle in degrees, positive to the right. */
113
- steer: number
114
- /** Wheel spin in rad/s (× radius ≈ the speed the tire is laying down). */
115
- spin: number
96
+ /**
97
+ * Aerodynamics. Both numbers are quoted the same way — **a fraction of the car's own weight at
98
+ * 30 m/s (108 km/h)** — and both grow with the SQUARE of speed from there, so `0.5` at 30 m/s is
99
+ * `2.0` at 60 m/s. One mental model for both, and no drag coefficients or frontal areas to invent.
100
+ */
101
+ export interface AeroConfig {
102
+ /**
103
+ * Downforce (default 0 — a car with no wing). The knob that decides whether a car corners better
104
+ * or worse the faster it goes: tire grip is a constant, but the force a corner demands grows with
105
+ * v², so with no downforce every car runs out of road at speed. `0.6` roughly doubles a car's
106
+ * cornering at 150 km/h; a road car is nearer `0.1`, a race car well past `1`.
107
+ *
108
+ * It presses along the CHASSIS' own down axis (that is what keeps a car planted over a crest) and
109
+ * only while a wheel is on the ground — a car on its roof is never launched by its own wing.
110
+ */
111
+ downforce?: number
112
+ /** Air resistance (default 0.03 — an ordinary car). What gives a car a top speed at all. */
113
+ drag?: number
116
114
  }
117
115
 
118
- /** How an axle's two wheels share torque.
119
- * - `'lsd'` — limited slip (Jolt's 1.4 ratio), the sane default.
120
- * - `'open'` — fully open: a lifted or spinning wheel takes ALL the torque.
121
- * - `'locked'` — both wheels forced to the same speed. Breaks traction predictably: the drift diff.
122
- * A number is the raw max/min wheel-speed ratio (> 1; smaller is more locked). */
123
- export type DifferentialMode = "lsd" | "open" | "locked"
124
-
125
- const DEFAULT_GEARS = [ 2.66, 1.78, 1.3, 1.0, 0.74 ]
126
- /** Floats vehicleGetState writes per wheel — see the WheelState fields. */
127
- const WHEEL_STATE = 8
128
- /** Limited-slip ratios: `<= 0` tells the host "fully open". */
116
+ /** Blob v5 — see bridges.d.ts for the field-by-field layout. */
117
+ const BLOB_VERSION = 5
118
+ const BLOB_HEADER = 21
119
+ const BLOB_WHEEL = 15
129
120
  const DIFFERENTIALS: Record<DifferentialMode, number> = { lsd: 1.4, open: 0, locked: 1 }
130
121
  const GRAVITY = 9.81 // only used to place the wheels at their resting height
131
122
  const AXLE_EPS = 0.35 // wheels within this many metres in Z are the same axle
132
123
  const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v)
133
124
 
134
- /** A wheel's STRUCTURE, resolved once at attach: where it is, what it does. The tunable numbers
135
- * (lock in degrees, brake torques) are applied in _buildSettings, so re-tuning picks them up. */
136
- type ResolvedWheel = {
137
- entityId: number
138
- cx: number, cy: number, cz: number // wheel centre at rest, chassis space
139
- radius: number
140
- width: number
141
- steers: boolean
142
- powered: boolean
143
- braked: boolean
144
- handbraked: boolean
145
- axle: number
146
- }
147
-
148
125
  export class Vehicle extends Aspect<"vehicle", Node> {
149
126
  static readonly aspect = "vehicle"
150
127
 
151
128
  /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
152
129
  mass = 1200
153
- /** The wheels, front to back. At least one; four is a car. */
154
- wheels: WheelConfig[] = []
155
- /** Which axle the engine drives (default "rear"). */
156
- powered: DriveLayout = "rear"
157
- /** Steering lock in degrees (default 35) for the wheels that steer — at a standstill. How much of
158
- * it survives at speed is `steering`'s job. */
159
- maxSteer = 35
130
+ /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to halfway
131
+ * down the chassis shape — a car with its mass at the shape's centre tips over. */
132
+ centerOfMass?: number
133
+ /** Which axle the engine drives (default "rear"). A wheel's own `drive` overrides it. */
134
+ driveAxle: DriveLayout = "rear"
135
+ /** Differential lock across each driven axle. `locked` is the drift setup — both wheels turn
136
+ * together, so the pair lets go as a pair. Live: re-configure the aspect to switch it mid-drive. */
137
+ differential: DifferentialMode | number = "lsd"
160
138
  /**
161
- * Speed-sensitive steering — what stops a car from over-driving its own front tires.
162
- *
163
- * A tire makes its peak grip at a few degrees of slip angle, and the steering angle a corner
164
- * actually needs falls with the SQUARE of speed. So holding full lock at 100 km/h asks the front
165
- * for several times what it can give: the tires saturate, the car ploughs straight on, and the
166
- * wheel stops answering. `atSpeed` is the lock left once the car reaches `speed`; in between it
167
- * eases on `(1 − v/speed)²`, which tracks the real curve closely.
139
+ * Anti-roll bars between each axle's wheels — what stops a car rolling onto its inside wheels in a
140
+ * fast corner. `true` (default) is a bar 0.01 as stiff as the wheel's own spring; a number is that
141
+ * ratio directly, and `0` / `false` removes the bars.
168
142
  *
169
- * `rate` is the other half: a keyboard asks for full lock in a single frame, which no steering
170
- * wheel can do, and that step alone is enough to snap a tire past its peak.
143
+ * **The numbers are far smaller than they look and the gain is steep** — Jolt's bar feeds
144
+ * `lengthDifference × stiffness × dt` as a bias into the suspension's own soft constraint, so 4%
145
+ * of the spring already flattens the body. Measured on a 1250 kg car: `0` → 1.3° of roll at 0.86 g,
146
+ * `0.01` → 0.6°, `0.15` → 0.2° (flat), `0.5` → the car HOPS.
171
147
  *
172
- * Both run inside the physics step, so they behave the same at any frame rate. `{ atSpeed: 0,
173
- * rate: 0 }` gives the raw lock, instantly — arcade-direct, and how it behaved before this existed.
148
+ * This is the knob for how much a car visibly LEANS, not for wheel lift: bars change how far the
149
+ * body tips, not how much load transfers. For lift, lower `centerOfMass`.
174
150
  */
175
- steering: SteeringConfig = { atSpeed: 10, speed: 30, rate: 220 }
176
- /**
177
- * Tire grip multiplier (default 1). Below 1 slides, above 1 sticks.
178
- *
179
- * This scales the tire's own friction curve. The grip a wheel ACTUALLY gets is combined with the
180
- * ground it stands on — `sqrt(tire × groundFriction)` — and a body's friction defaults to 0.6, so
181
- * a road you want a car to really corner on wants `Physics { friction: 1 }` (read
182
- * `wheel(i).gripLateral` to see what the tires actually got).
183
- */
184
- grip = 1
185
- /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side.
186
- * Nothing to do with the tires; those are `grip` × the ground's friction. */
187
- chassisFriction = 0.6
188
- /** Foot-brake torque per wheel in N·m (default 1500); the handbrake pulls 3× on its wheels. */
189
- brakeTorque = 1500
190
- /** Anti-roll bars between each axle's wheels (default on) — what keeps a car from tripping in a
191
- * fast corner. */
192
- antiRoll = true
151
+ antiRoll: boolean | number = true
193
152
  /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. */
194
153
  maxTilt = 60
195
- /** Suspension: one travel distance, one stiffness, one damping. */
196
- suspension: SuspensionConfig = { travel: 0.3, stiffness: 1.5, damping: 0.5 }
197
- /** Engine: peak torque + redline. */
198
- engine: EngineConfig = { torque: 500, maxRpm: 6000 }
199
- /** Forward gear ratios, shifted automatically. Reverse is fixed. */
200
- gears: number[] = DEFAULT_GEARS
201
- /** Differential lock across each driven axle (and between axles on an all-wheel-drive car).
202
- * `'locked'` is the drift setup — both wheels turn together, so the pair lets go as a pair.
203
- * Live: re-configure the aspect to switch it mid-drive (see `onReconfigure`). */
204
- differential: DifferentialMode | number = "lsd"
205
- /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to
206
- * halfway down the chassis shape — a car with its mass at the shape's centre tips over. */
207
- centerOfMass?: number
208
-
209
- /** Scene-editor inspector: the flat, tunable half. */
154
+ /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side. Nothing
155
+ * to do with the tires; those are the `Wheel`'s `tire.grip` × the ground's friction. */
156
+ friction = DEFAULT_FRICTION
157
+ /** Strength of the clutch when fully engaged (default 10). Small values slip; large ones weld the
158
+ * engine to the wheels. The `Gearbox` scales it 0..1 while shifting. */
159
+ clutchStrength = 10
160
+
161
+ engine: EngineConfig = { torque: 500, maxRpm: 6000, idleRpm: 1000, inertia: 0.5, braking: 0.2 }
162
+ steering: SteeringConfig = { lock: 35, atSpeed: 10, speed: 30, rate: 220 }
163
+ aero: AeroConfig = { downforce: 0, drag: 0.03 }
164
+
165
+ /** Scene-editor inspector: the flat, tunable half. The grouped objects are edited in code. */
210
166
  static fields: FieldMeta<Vehicle> = {
211
167
  mass: { min: 1, step: 10 },
212
- powered: { label: "Drive", options: [ "rear", "front", "all" ] },
213
- maxSteer: { label: "Steering lock", min: 0, max: 80, step: 1 },
214
- grip: { min: 0.1, max: 3, step: 0.05 },
215
- chassisFriction: { label: "Chassis friction", min: 0, max: 2, step: 0.05 },
216
- brakeTorque: { label: "Brake torque", min: 0, step: 50 },
217
- maxTilt: { label: "Max tilt", min: 0, max: 180, step: 5 },
168
+ driveAxle: { label: "Drive", options: [ "rear", "front", "all" ] },
218
169
  differential: { options: [ "lsd", "open", "locked" ] },
219
- wheels: { hidden: true },
220
- gears: { hidden: true },
221
- suspension: { hidden: true },
170
+ antiRoll: { label: "Anti-roll" },
171
+ maxTilt: { label: "Max tilt", min: 0, max: 180, step: 5 },
172
+ friction: { label: "Chassis friction", min: 0, max: 2, step: 0.05 },
173
+ clutchStrength: { label: "Clutch", min: 0.5, max: 100, step: 0.5 },
222
174
  engine: { hidden: true },
223
175
  steering: { hidden: true },
176
+ aero: { hidden: true },
224
177
  }
225
178
  static editor = { rebuild: true }
226
179
 
227
180
  private _id = 0
228
181
  private _bodyId = 0
229
182
  private _builtMass = 0
230
- private _resolved: ResolvedWheel[] = []
231
- private _state = new Float32Array(7 + WHEEL_STATE)
183
+ private _wheels: Wheel[] = []
184
+ private _state = new Float32Array(STATE_HEADER)
232
185
  private _stateFrame = -1
186
+ private _throttle = 0
187
+ private _steer = 0
188
+ private _brake = 0
189
+ private _handbrake = 0
233
190
 
234
191
  onAttach(): void {
235
192
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
@@ -247,26 +204,42 @@ export class Vehicle extends Aspect<"vehicle", Node> {
247
204
  if (this.node.has(Physics)) {
248
205
  throw new Error("Vehicle owns its own rigid body — remove the Physics aspect from the chassis node (put `mass` on the Vehicle instead)")
249
206
  }
250
- if (this.wheels.length === 0) throw new Error("Vehicle needs at least one wheel — `wheels: [{ node: … }, …]`")
251
207
 
252
- this._resolved = this._resolve()
208
+ this._wheels = this._collectWheels()
209
+ if (this._wheels.length === 0) {
210
+ throw new Error("Vehicle found no wheels — put a Wheel aspect on each wheel node under the chassis: wheelFL.aspect(Wheel)")
211
+ }
212
+
253
213
  const shapeId = shape._claim() // the vehicle owns the shape; drop the pick-only body
254
- const ids = new Uint32Array(this._resolved.map((w) => w.entityId))
255
- this._id = _creator.vehicleCreate(this.node.id, shapeId, this._buildSettings(), ids)
214
+ this._id = _creator.vehicleCreate(this.node.id, shapeId, this._buildSettings())
256
215
  if (!this._id) {
257
216
  console.warn(`Vehicle: the engine refused the settings for "${this.node.name}" — check the wheel list`)
258
217
  shape._recreatePickBody()
259
218
  return
260
219
  }
261
220
  this._bodyId = _creator.vehicleBodyId ? _creator.vehicleBodyId(this._id) : 0
262
- if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.chassisFriction)
221
+ // The chassis body owns the node's transform, so route position writes to it (see Node._xf) —
222
+ // `car.position = p` places the car instead of being silently overwritten by the next sync. It is
223
+ // the WEAK form of the operation: it keeps the car's velocity and its roll, so a respawn still
224
+ // wants `reset(p)`, which also uprights it and clears engine, gearbox and wheel spin.
225
+ this.node._xf = this._bodyId
226
+ this.node._xfKind = this._bodyId ? 2 : 0
227
+ if (this._bodyId && this.friction !== DEFAULT_FRICTION) {
228
+ _creator.physicsSetFriction?.(this._bodyId, this.friction)
229
+ }
263
230
  this._builtMass = this.mass
264
- this._state = new Float32Array(7 + this._resolved.length * WHEEL_STATE)
231
+ this._state = new Float32Array(STATE_HEADER + this._wheels.length * WHEEL_STATE)
232
+ // A car with no gearbox still has to drive: install the default automatic.
233
+ if (!this.node.has(Gearbox)) this.node.aspect(Gearbox)
265
234
  }
266
235
 
267
236
  onDetach(): void {
268
237
  if (this._id) { _creator.vehicleDestroy(this._id); this._id = 0; this._bodyId = 0 }
269
- this.node.get(Shape)?._recreatePickBody()
238
+ for (const w of this._wheels) w._vehicle = null
239
+ this._wheels = []
240
+ this.node._xf = 0
241
+ this.node._xfKind = 0
242
+ this.node.get(Shape)?._recreatePickBody() // hands the routing back to the pick body
270
243
  }
271
244
 
272
245
  /**
@@ -275,41 +248,42 @@ export class Vehicle extends Aspect<"vehicle", Node> {
275
248
  *
276
249
  * car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })
277
250
  *
278
- * Live: `differential`, `grip`, `maxSteer`, `steering`, `engine`, `gears`, `brakeTorque`,
279
- * `maxTilt`, `chassisFriction`. Everything else describes the car's STRUCTURE — `mass`,
280
- * `centerOfMass`, `wheels`, `suspension`, `powered`, `antiRoll` — and is baked into the constraint
281
- * at attach; to change one of those, detach and attach the aspect again.
251
+ * Live: `differential`, `engine`, `steering`, `aero`, `maxTilt`, `friction`, `clutchStrength`, and
252
+ * every `Wheel`'s `tire.grip` and brake torques. Everything else describes the car's STRUCTURE —
253
+ * `mass`, `centerOfMass`, which wheels exist and where, their suspension, `driveAxle`, `antiRoll` — and
254
+ * is baked into the constraint at attach; to change one of those, re-attach the aspect.
282
255
  */
283
256
  onReconfigure(): void {
284
257
  if (!this._id) return
285
- if (this.wheels.length !== this._resolved.length) {
286
- console.warn("Vehicle: the wheel list can't change on a live car — remove and re-add the aspect")
287
- return
288
- }
289
258
  if (this.mass !== this._builtMass) {
290
259
  console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — re-add the aspect to change it`)
291
260
  this.mass = this._builtMass
292
261
  }
293
262
  _creator.vehicleSetTuning?.(this._id, this._buildSettings())
294
- if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.chassisFriction)
263
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
295
264
  }
296
265
 
297
266
  /** Native vehicle id (0 until attached / no physics support). */
298
267
  get id(): number { return this._id }
299
268
  /** The chassis rigid-body id — the same handle the plain body calls take. */
300
269
  get bodyId(): number { return this._bodyId }
270
+ /** The wheels, in blob order (front axle first). */
271
+ get wheels(): readonly Wheel[] { return this._wheels }
301
272
 
302
273
  // --- driving --------------------------------------------------------------------------------
303
274
 
304
275
  /**
305
276
  * The driver's controls, all in [-1, 1] (brakes in [0, 1]). Sticky: the last values keep applying
306
- * until the next call, so a held key does not need re-sending. `forward < 0` engages reverse once
307
- * the car has stopped (the gearbox is automatic).
277
+ * until the next call, so a held key does not need re-sending. `forward < 0` asks for reverse; the
278
+ * `Gearbox` engages it once the car has stopped.
308
279
  */
309
280
  drive(forward: number, steer = 0, brake = 0, handbrake = 0): this {
310
281
  if (this._id) {
311
- _creator.vehicleSetInput(this._id, clamp(forward, -1, 1), clamp(steer, -1, 1),
312
- clamp(brake, 0, 1), clamp(handbrake, 0, 1))
282
+ this._throttle = clamp(forward, -1, 1)
283
+ this._steer = clamp(steer, -1, 1)
284
+ this._brake = clamp(brake, 0, 1)
285
+ this._handbrake = clamp(handbrake, 0, 1)
286
+ _creator.vehicleSetInput(this._id, this._throttle, this._steer, this._brake, this._handbrake)
313
287
  }
314
288
  return this
315
289
  }
@@ -324,6 +298,8 @@ export class Vehicle extends Aspect<"vehicle", Node> {
324
298
  const q: QuatLike = rotation ?? [ 0, wq.y, 0, wq.w ]
325
299
  _creator.vehicleReset(this._id, cx(p), cy(p), cz(p), cx(q), cy(q), cz(q), cw(q))
326
300
  this._stateFrame = -1
301
+ const box = this.node.get(Gearbox)
302
+ if (box) box.gear = 0
327
303
  return this
328
304
  }
329
305
 
@@ -333,7 +309,12 @@ export class Vehicle extends Aspect<"vehicle", Node> {
333
309
  return this
334
310
  }
335
311
 
336
- // --- readouts (one native read per frame, shared by every getter) ----------------------------
312
+ /** @internal The Gearbox's latched pair: the one ratio the engine runs, and the clutch envelope. */
313
+ _setTransmission(ratio: number, clutch: number): void {
314
+ if (this._id) _creator.vehicleSetTransmission(this._id, ratio, clamp(clutch, 0, 1))
315
+ }
316
+
317
+ // --- readouts (one native read per frame, shared by the car and every wheel) -------------------
337
318
 
338
319
  private _read(): Float32Array {
339
320
  if (this._id && this._stateFrame !== glState.frame) {
@@ -343,177 +324,150 @@ export class Vehicle extends Aspect<"vehicle", Node> {
343
324
  return this._state
344
325
  }
345
326
 
327
+ /** @internal One value out of wheel `index`'s slice — see WHEEL_STATE in Wheel.ts. */
328
+ _wheelState(index: number, k: number): number {
329
+ const s = this._read()
330
+ const at = STATE_HEADER + index * WHEEL_STATE + k
331
+ return at < s.length ? s[at]! : 0
332
+ }
333
+
346
334
  /** Speed along the car's forward axis in m/s — negative when reversing (× 3.6 for km/h). */
347
335
  get speed(): number { return this._read()[0]! }
348
336
  /** Engine revolutions per minute. */
349
337
  get rpm(): number { return this._read()[1]! }
350
- /** Current gear: -1 reverse, 0 neutral, 1… forward. */
351
- get gear(): number { return this._read()[2]! }
352
338
  /** How many wheels are touching the ground. */
353
- get wheelsOnGround(): number { return this._read()[3]! }
339
+ get wheelsOnGround(): number { return this._read()[2]! }
354
340
  /** Any wheel on the ground — false while airborne. */
355
- get grounded(): boolean { return this._read()[3]! > 0 }
341
+ get grounded(): boolean { return this._read()[2]! > 0 }
356
342
  /** Chassis velocity in world units/s (fresh Vec3). */
357
- get velocity(): Vec3 { const s = this._read(); return new Vec3(s[4]!, s[5]!, s[6]!) }
358
- /** The steering lock, in degrees, the car is allowed at its current speed — `maxSteer` at rest,
343
+ get velocity(): Vec3 { const s = this._read(); return new Vec3(s[3]!, s[4]!, s[5]!) }
344
+ /** The last throttle handed to `drive()` — what the gearbox reads to know which way you want to go. */
345
+ get throttle(): number { return this._throttle }
346
+ /** The last steer input handed to `drive()`, [-1, 1]. */
347
+ get steerInput(): number { return this._steer }
348
+ /** The last foot-brake input handed to `drive()`, [0, 1] — the pedal, not the resulting force. */
349
+ get brake(): number { return this._brake }
350
+ /** The last handbrake input handed to `drive()`, [0, 1]. */
351
+ get handbrake(): number { return this._handbrake }
352
+ /** Current gear from the attached `Gearbox`: −1 reverse, 0 neutral, 1… forward. */
353
+ get gear(): number { return this.node.get(Gearbox)?.gear ?? 0 }
354
+ /** The steering lock, in degrees, the car is allowed at its current speed — the full lock at rest,
359
355
  * falling to `steering.atSpeed`. The same curve the engine applies; useful on a HUD. */
360
356
  get steerLock(): number {
357
+ const lock = this.steering.lock ?? 35
361
358
  const atSpeed = Math.max(0, this.steering.atSpeed ?? 10)
362
- if (atSpeed <= 0 || atSpeed >= this.maxSteer) return this.maxSteer
359
+ if (atSpeed <= 0 || atSpeed >= lock) return lock
363
360
  const full = this.steering.speed ?? 30
364
361
  const t = full > 0 ? Math.min(Math.abs(this.speed) / full, 1) : 1
365
- return atSpeed + (this.maxSteer - atSpeed) * (1 - t) * (1 - t)
366
- }
367
-
368
- /** One wheel's live state, by its index in `wheels`. */
369
- wheel(index: number): WheelState {
370
- const s = this._read()
371
- const base = 7 + index * WHEEL_STATE
372
- if (index < 0 || base + WHEEL_STATE > s.length) {
373
- return { contact: false, slip: 0, slipAngle: 0, gripLong: 0, gripLateral: 0, suspension: 0, steer: 0, spin: 0 }
374
- }
375
- return {
376
- contact: s[base] !== 0,
377
- slip: s[base + 1]!,
378
- slipAngle: s[base + 2]!,
379
- gripLong: s[base + 3]!,
380
- gripLateral: s[base + 4]!,
381
- suspension: s[base + 5]!,
382
- steer: s[base + 6]!,
383
- spin: s[base + 7]!,
384
- }
362
+ return atSpeed + (lock - atSpeed) * (1 - t) * (1 - t)
385
363
  }
386
364
 
387
365
  // --- setup ----------------------------------------------------------------------------------
388
366
 
389
- /** Resolve every wheel default: node → entity id + centre, Z → axle, layout → steer/power/brakes. */
390
- private _resolve(): ResolvedWheel[] {
367
+ /** Every Wheel under the chassis, ordered front axle first, with its axle group resolved. */
368
+ private _collectWheels(): Wheel[] {
369
+ const found: Wheel[] = []
370
+ this.node.traverse((n) => { const w = n.get(Wheel); if (w) found.push(w) })
371
+ if (found.length === 0) return found
372
+
391
373
  const chassisInv = this.node.worldMatrix.invert()
392
- const centres = this.wheels.map((w) => {
393
- if (w.position) return new Vec3(cx(w.position), cy(w.position), cz(w.position))
394
- const node = this._wheelNode(w)
395
- if (!node) {
396
- throw new Error("Vehicle: a wheel needs either `position` or a `node` to take its position from")
397
- }
398
- return chassisInv.mul(node.worldMatrix).position
399
- })
400
-
401
- // Axles: sort by Z (front first) and start a new group whenever the gap is more than AXLE_EPS.
402
- const axleOf = new Array<number>(this.wheels.length).fill(0)
403
- // Forward is −Z, so the front axle is the most NEGATIVE z.
404
- const byZ = centres.map((_, i) => i).sort((a, b) => centres[a]!.z - centres[b]!.z)
374
+ const centres = found.map((w) => chassisInv.mul(w.node.worldMatrix).position)
375
+
376
+ // Axles: sort by Z (forward is −Z, so the front axle is the most NEGATIVE) and start a new group
377
+ // whenever the gap is more than AXLE_EPS.
378
+ const order = centres.map((_, i) => i).sort((a, b) => centres[a]!.z - centres[b]!.z)
379
+ const axleOf = new Array<number>(found.length).fill(0)
405
380
  let axle = 0
406
- let ref = centres[byZ[0]!]!.z
407
- for (const i of byZ) {
408
- const w = this.wheels[i]!
381
+ let ref = centres[order[0]!]!.z
382
+ for (const i of order) {
409
383
  if (Math.abs(centres[i]!.z - ref) > AXLE_EPS) { axle++; ref = centres[i]!.z }
410
- axleOf[i] = w.axle ?? axle
384
+ axleOf[i] = found[i]!.axle ?? axle
411
385
  }
412
386
  const maxAxle = Math.max(...axleOf)
413
387
 
414
- return this.wheels.map((w, i) => {
388
+ // Bind each wheel in front-to-back order, resolving what it did not say.
389
+ const wheels: Wheel[] = []
390
+ for (const i of order) {
391
+ const w = found[i]!
415
392
  const a = axleOf[i]!
416
- const steers = w.steer ?? a === 0
417
- const powered = w.powered ?? (this.powered === "all" || (this.powered === "front" ? a === 0 : a === maxAxle))
418
- const handbrake = w.handbrake ?? !steers
419
- const c = centres[i]!
420
- const node = this._wheelNode(w)
421
- return {
422
- entityId: node ? node.id : 0,
423
- cx: c.x, cy: c.y, cz: c.z,
424
- radius: w.radius ?? 0.35,
425
- width: w.width ?? 0.25,
426
- steers,
427
- powered,
428
- braked: w.brake ?? true,
429
- handbraked: handbrake,
430
- axle: a,
431
- }
432
- })
433
- }
434
-
435
- private _wheelNode(w: WheelConfig): Node | null {
436
- if (!w.node) return null
437
- if (typeof w.node !== "string") return w.node
438
- const found = this.node.bone(w.node)
439
- if (!found) console.warn(`Vehicle: no node named "${w.node}" under "${this.node.name}" — that wheel will be invisible`)
440
- return found
393
+ const measured = w._measure()
394
+ w.radius ??= measured.radius
395
+ w.width ??= measured.width
396
+ w.steer ??= a === 0
397
+ w.drive ??= this.driveAxle === "all" || (this.driveAxle === "front" ? a === 0 : a === maxAxle)
398
+ w.handbrake ??= !w.steer
399
+ w.axle = a
400
+ const rest = centres[i]!
401
+ // Where the suspension hangs from, so the wheel CENTRE ends up where the author put it: the
402
+ // spring sags by g/(2πf)² under its own load (mass-independent), and that sag is what separates
403
+ // the resting length from full droop.
404
+ const travel = w.suspension.travel ?? 0.4
405
+ const stiffness = w.suspension.stiffness ?? 1.4
406
+ const sag = Math.min(GRAVITY / ((2 * Math.PI * stiffness) ** 2), travel * 0.6)
407
+ const attach = new Vec3(rest.x, rest.y + (travel - sag), rest.z)
408
+ w._bind(this, wheels.length, rest, attach)
409
+ wheels.push(w)
410
+ }
411
+ return wheels
441
412
  }
442
413
 
443
414
  /** Pack the whole configuration into the one blob the host ABI takes (see bridges.d.ts). */
444
415
  private _buildSettings(): Float32Array {
445
- const travel = this.suspension.travel ?? 0.3
446
- const stiffness = this.suspension.stiffness ?? 1.5
447
- const damping = this.suspension.damping ?? 0.5
448
- const maxRpm = this.engine.maxRpm ?? 6000
449
- const gears = this.gears.length ? this.gears : DEFAULT_GEARS
450
- // Where the suspension hangs from, so the wheel CENTRE ends up where the author put it: the
451
- // spring sags by g/(2πf)² under its own load (mass-independent), and that sag is what separates
452
- // the resting length from full droop.
453
- const sag = Math.min(GRAVITY / ((2 * Math.PI * stiffness) ** 2), travel * 0.6)
454
- const attachOffset = travel - sag
455
-
416
+ const curve = this.engine.curve ?? []
417
+ const curveCount = Math.floor(curve.length / 2)
456
418
  const diff = typeof this.differential === "number"
457
419
  ? this.differential
458
420
  : DIFFERENTIALS[this.differential] ?? DIFFERENTIALS.lsd
459
421
 
460
- const out = new Float32Array(21 + gears.length + this._resolved.length * 10)
461
- out[0] = 3 // blob version
422
+ const out = new Float32Array(BLOB_HEADER + curveCount * 2 + this._wheels.length * BLOB_WHEEL)
423
+ out[0] = BLOB_VERSION
462
424
  out[1] = this.mass
463
425
  out[2] = this.centerOfMass === undefined ? 1 : 0
464
426
  out[3] = this.centerOfMass ?? 0
465
- out[4] = travel
466
- out[5] = stiffness
467
- out[6] = damping
468
- out[7] = this.engine.torque ?? 500
469
- out[8] = maxRpm / 6
470
- out[9] = maxRpm
471
- out[10] = maxRpm * 0.67 // shift up
472
- out[11] = maxRpm * 0.33 // shift down
473
- out[12] = this.grip
474
- out[13] = this.antiRoll ? 1 : 0
475
- out[14] = this.maxTilt
476
- out[15] = gears.length
477
- out[16] = this._resolved.length
478
- out[17] = diff // v2: limited-slip ratio (<= 0 = open)
479
- out[18] = Math.max(0, this.steering.atSpeed ?? 10) // v3: speed-sensitive steering
480
- out[19] = this.steering.speed ?? 30
481
- out[20] = Math.max(0, this.steering.rate ?? 220)
482
- let at = 21
483
- for (const g of gears) out[at++] = g
484
- for (const w of this._resolved) {
485
- out[at++] = w.cx
486
- out[at++] = w.cy + attachOffset
487
- out[at++] = w.cz
488
- out[at++] = w.radius
489
- out[at++] = w.width
490
- out[at++] = w.steers ? this.maxSteer : 0
491
- out[at++] = w.powered ? 1 : 0
492
- out[at++] = w.braked ? this.brakeTorque : 0
493
- out[at++] = w.handbraked ? this.brakeTorque * 3 : 0
494
- out[at++] = w.axle
427
+ out[4] = this.engine.torque ?? 500
428
+ out[5] = this.engine.maxRpm ?? 6000
429
+ out[6] = this.engine.idleRpm ?? 1000
430
+ out[7] = this.engine.inertia ?? 0.5
431
+ out[8] = this.engine.braking ?? 0.2
432
+ out[9] = this.clutchStrength
433
+ out[10] = diff
434
+ out[11] = this.antiRoll === true ? 0.01 : this.antiRoll === false ? 0 : Math.max(0, this.antiRoll)
435
+ out[12] = this.maxTilt
436
+ out[13] = this.steering.lock ?? 35
437
+ out[14] = Math.max(0, this.steering.atSpeed ?? 10)
438
+ out[15] = this.steering.speed ?? 30
439
+ out[16] = Math.max(0, this.steering.rate ?? 220)
440
+ out[17] = Math.max(0, this.aero.downforce ?? 0)
441
+ out[18] = Math.max(0, this.aero.drag ?? 0.03)
442
+ out[19] = this._wheels.length
443
+ out[20] = curveCount
444
+ let at = BLOB_HEADER
445
+ for (let i = 0; i < curveCount * 2; i++) out[at++] = curve[i]!
446
+ for (const w of this._wheels) {
447
+ out[at++] = w._attach.x
448
+ out[at++] = w._attach.y
449
+ out[at++] = w._attach.z
450
+ out[at++] = w.radius ?? 0.35
451
+ out[at++] = w.width ?? 0.25
452
+ out[at++] = w.steer ? (this.steering.lock ?? 35) : 0
453
+ out[at++] = w.drive ? 1 : 0
454
+ out[at++] = w.brake ? w.brakeTorque : 0
455
+ out[at++] = w.handbrake ? w.brakeTorque * 3 : 0
456
+ out[at++] = w.axle ?? 0
457
+ out[at++] = w.suspension.travel ?? 0.4
458
+ out[at++] = w.suspension.stiffness ?? 1.4
459
+ out[at++] = w.suspension.damping ?? 0.5
460
+ out[at++] = w.tire.grip ?? 1
461
+ out[at++] = w.tire.curve === "arcade" ? 1 : 0
495
462
  }
496
463
  return out
497
464
  }
498
465
 
499
- /** Edit mode: wheels as circles at their resting height with their travel range, plus a forward
500
- * arrow — enough to place a wheel without running the game. Play mode never calls this. */
466
+ /** Edit mode: which way the chassis faces. Each wheel draws its own circle and travel range — see
467
+ * `Wheel.rebuild()`. Play mode never calls this. */
501
468
  rebuild(): void {
502
- let resolved: ResolvedWheel[]
503
- try { resolved = this._resolve() } catch { return }
504
- const world = new Mat4(this.node.worldMatrix)
505
- const travel = this.suspension.travel ?? 0.3
506
- const P = (x: number, y: number, z: number): Vec3 => world.transformPoint([ x, y, z ])
507
- for (const w of resolved) {
508
- const ring: Vec3[] = []
509
- for (let i = 0; i < 24; i++) {
510
- const t = (i / 24) * Math.PI * 2
511
- ring.push(P(w.cx, w.cy + Math.sin(t) * w.radius, w.cz + Math.cos(t) * w.radius))
512
- }
513
- Gizmos.polyline(ring, { color: w.steers ? "#5b8ef0" : "#9aa3b2", closed: true })
514
- Gizmos.line(P(w.cx, w.cy + travel * 0.6, w.cz), P(w.cx, w.cy - travel * 0.4, w.cz), { color: "#59606d" })
515
- if (w.powered) Gizmos.cross(P(w.cx, w.cy, w.cz), w.radius * 0.35, { color: "#e0b25a" })
516
- }
469
+ const w = new Mat4(this.node.worldMatrix)
470
+ const P = (x: number, y: number, z: number): Vec3 => w.transformPoint([ x, y, z ])
517
471
  Gizmos.polyline([ P(0, 0, 0), P(0, 0, -1.2), P(-0.15, 0, -1.0), P(0, 0, -1.2), P(0.15, 0, -1.0) ], { color: "#ffffff" })
518
472
  }
519
473
  }