lecodes-cli 0.18.1 → 0.19.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.
@@ -8,6 +8,7 @@
8
8
  import { Color, type ColorInput } from "../core/color"
9
9
  import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
10
10
  import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
11
+ import { device } from "../runtime/device"
11
12
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
12
13
  import type { FetchResponse } from "../runtime/fetch"
13
14
  import { _requestCameraPermission } from "../plugins/permission"
@@ -115,6 +116,14 @@ export type SceneOptions = {
115
116
  * scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
116
117
  * On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
117
118
  anisotropy?: number
119
+ /** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
120
+ * macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
121
+ * display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
122
+ * 1..8 is where white lands as a multiple of SDR white — the "HDR brightness" of a console
123
+ * calibration screen, where 1.5–2 is the norm; default 1 keeps a white wall at the UI's white,
124
+ * faithful but dim next to what "HDR on" is expected to look like. Engine-wide like `anisotropy`;
125
+ * `device.hdr` is the runtime form and tells you whether the display has any headroom at all. */
126
+ hdr?: { strength?: number; paperWhite?: number }
118
127
  }
119
128
 
120
129
  // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
@@ -132,6 +141,13 @@ const installRenderSyncedFrames = (): void => {
132
141
  _creator.setEarlyUpdate((dt: number) => { glState.frame++; early(dt) })
133
142
  _creator.setLateUpdate(late)
134
143
  return true
144
+ }, (fixed) => {
145
+ // The fixed phase runs INSIDE the engine's substep loop (physics/world.cpp) when the host has the
146
+ // hook; each substep moves bodies natively, so the frame stamp bumps per step too. Feature-detected:
147
+ // an older host returns false and the dispatcher steps its own 1/60 accumulator.
148
+ if (typeof _creator.setFixedUpdate !== "function") return false
149
+ _creator.setFixedUpdate((dt: number) => { glState.frame++; fixed(dt) })
150
+ return true
135
151
  })
136
152
  // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
137
153
  // setTimeScale; a host without it keeps running its sims at wall-clock speed (feature-detected).
@@ -188,6 +204,10 @@ export class Scene implements Presentable {
188
204
  // Before anything else in this constructor that could load a texture: the value has to be in
189
205
  // place by the time assets bind their samplers.
190
206
  if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
207
+ if (options.hdr) {
208
+ if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
209
+ if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
210
+ }
191
211
  if (options.renderScale !== undefined || options.dynamicResolution) {
192
212
  this.setRenderOptions(options.renderScale ?? 1, options.dynamicResolution ?? false)
193
213
  }
@@ -22,7 +22,11 @@ import { cx, cy, cz, type Vec3Like } from "../math/vec"
22
22
  import type { Node } from "./Node"
23
23
 
24
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"
25
+ export type ShapeKind = "auto" | "box" | "sphere" | "cylinder" | "capsule" | "mesh" | "convex" | "heightfield"
26
+
27
+ /** What a terrain node carries for `Shape { heightfield: true }` (gl/Terrain.ts `_data`). */
28
+ type TerrainData = { sizeX: number, sizeZ: number, cellSize: number, heights: Float32Array, holes: Uint8Array | null }
29
+ const terrainOf = (node: Node): TerrainData | null => (node as unknown as { _terrain?: { _data: TerrainData } })._terrain?._data ?? null
26
30
 
27
31
  /** A node-local AABB, in the node's OWN frame (its own transform excluded), before world scale. */
28
32
  export type ShapeBounds = { min: [number, number, number], max: [number, number, number] }
@@ -60,6 +64,10 @@ export class Shape extends Aspect<"shape", Node> {
60
64
  * only (a dynamic body can't be a triangle mesh; Physics throws). `'convex'` = convex hull of the
61
65
  * vertices, works for dynamic bodies too. A Model uses its bind pose (skinned parts skipped). */
62
66
  mesh?: boolean | "convex"
67
+ /** The node's terrain (`Terrain.create(...).node`) as a Jolt height field — the exact drawn grid,
68
+ * holes included (docs/terrain-plan.md). Static / kinematic / picking / character ground only,
69
+ * like `mesh: true`. `terrain.commit()` pushes edits into the live shape. */
70
+ heightfield?: boolean
63
71
  /**
64
72
  * The shape's centre, relative to the node's origin — world units in the node's ROTATED frame
65
73
  * (the node's scale is ignored, exactly like the explicit dimensions above). Default [0, 0, 0].
@@ -140,6 +148,7 @@ export class Shape extends Aspect<"shape", Node> {
140
148
 
141
149
  /** Which geometry this shape currently uses (the `_build` dispatch, as a name). */
142
150
  get kind(): ShapeKind {
151
+ if (this.heightfield) return "heightfield"
143
152
  if (this.mesh === "convex") return "convex"
144
153
  if (this.mesh) return "mesh"
145
154
  if (this.sphere !== undefined) return "sphere"
@@ -238,8 +247,14 @@ export class Shape extends Aspect<"shape", Node> {
238
247
  if (!this.raycast && this._bodyId) _creator.physicsSetPickable(this._bodyId, false)
239
248
  }
240
249
 
241
- /** @internal true for an exact triangle mesh — Physics refuses a dynamic body on it. */
242
- get _isTriangleMesh(): boolean { return this.mesh === true }
250
+ /** @internal true for an exact triangle mesh or a height field — Physics refuses a dynamic body on it. */
251
+ get _isTriangleMesh(): boolean { return this.mesh === true || this.heightfield === true }
252
+
253
+ /** @internal the height range a heightfield shape can encode (`Terrain.commit` rebuilds past it). */
254
+ _heightfieldRange: [number, number] | null = null
255
+
256
+ /** @internal rebuild the geometry in place (a terrain edit that left the encodable range). */
257
+ _rebuildShape(): void { this._rebuild() }
243
258
 
244
259
  // Rebuild the geometry in place: the body keeps its id, velocity and world transform (Physics
245
260
  // holds that id, so re-creating the body instead would strand it). A shape a CharacterController
@@ -290,7 +305,8 @@ export class Shape extends Aspect<"shape", Node> {
290
305
  this._autoOrigin = null
291
306
  this._sig = this._signature()
292
307
  let id = 0
293
- if (this.mesh) id = this._buildMesh(this.mesh === "convex")
308
+ if (this.heightfield) id = this._buildHeightField()
309
+ else if (this.mesh) id = this._buildMesh(this.mesh === "convex")
294
310
  else if (this.sphere !== undefined) id = _creator.physicsBuildSphere(this.sphere)
295
311
  else if (this.cylinder) id = _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
296
312
  else if (this.capsule) id = _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
@@ -321,7 +337,23 @@ export class Shape extends Aspect<"shape", Node> {
321
337
  private _signature(): string {
322
338
  const v = (a: Vec3Like | undefined) => (a ? `${cx(a)},${cy(a)},${cz(a)}` : "")
323
339
  const t = (o: { halfHeight: number, radius: number } | undefined) => (o ? `${o.halfHeight},${o.radius}` : "")
324
- return `${v(this.box)}|${this.sphere ?? ""}|${t(this.cylinder)}|${t(this.capsule)}|${this.mesh ?? ""}|${v(this.origin)}`
340
+ return `${v(this.box)}|${this.sphere ?? ""}|${t(this.cylinder)}|${t(this.capsule)}|${this.mesh ?? ""}|${this.heightfield ? "hf" : ""}|${v(this.origin)}`
341
+ }
342
+
343
+ // heightfield: the terrain on this node hands its grid over; the engine keeps its own copy and
344
+ // pads it to Jolt's block grid. No terrain / no host support = no collider (0), with a warning.
345
+ private _buildHeightField(): number {
346
+ const t = terrainOf(this.node)
347
+ if (!t) { console.warn(`Shape { heightfield }: "${this.node.name}" carries no Terrain — no collider`); return 0 }
348
+ if (!_creator.physicsBuildHeightField) { console.warn("Shape { heightfield } needs a newer host — the terrain has no collider"); return 0 }
349
+ const s = this.node.worldScale
350
+ let lo = Infinity, hi = -Infinity
351
+ for (let i = 0; i < t.heights.length; i++) { const h = t.heights[i]; if (h < lo) lo = h; if (h > hi) hi = h }
352
+ const range = Math.max(1, hi - lo), margin = Math.max(2, range * 0.25)
353
+ this._heightfieldRange = [ lo - margin, hi + margin ] // mirrors creator-gl physicsBuildHeightField's headroom
354
+ const id = _creator.physicsBuildHeightField(t.heights, t.sizeX, t.sizeZ, t.cellSize, t.holes, s.x, s.y, s.z)
355
+ if (!id) console.warn(`Shape { heightfield }: the engine refused "${this.node.name}"'s grid (all holes?)`)
356
+ return id
325
357
  }
326
358
 
327
359
  // mesh: a Mesh node hands its CPU geometry over; anything else is tried as a GLB root (the host