@jgengine/shell 0.15.0-next.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Shell3dPresentation.js +1 -1
  3. package/dist/audio/synthEngine.js +12 -2
  4. package/dist/environment/EnvironmentScene.js +1 -1
  5. package/dist/presentationResolve.d.ts +2 -0
  6. package/dist/presentationResolve.js +1 -0
  7. package/dist/render/modelLoad.d.ts +24 -0
  8. package/dist/render/modelLoad.js +46 -0
  9. package/dist/scene/CityRenderer.js +28 -54
  10. package/dist/structures/BuildingKitBatch.d.ts +13 -0
  11. package/dist/structures/BuildingKitBatch.js +78 -0
  12. package/dist/structures/GeneratedBuilding.d.ts +19 -3
  13. package/dist/structures/GeneratedBuilding.js +13 -81
  14. package/dist/structures/buildingKitFit.d.ts +59 -0
  15. package/dist/structures/buildingKitFit.js +188 -0
  16. package/dist/structures/buildingKitRegistry.d.ts +9 -0
  17. package/dist/structures/buildingKitRegistry.js +26 -0
  18. package/dist/weather/DustField.d.ts +41 -0
  19. package/dist/weather/DustField.js +129 -0
  20. package/dist/weather/WeatherLayer.d.ts +8 -2
  21. package/dist/weather/WeatherLayer.js +5 -2
  22. package/dist/weather/index.d.ts +1 -0
  23. package/dist/weather/index.js +1 -0
  24. package/dist/weather/weatherMath.d.ts +4 -0
  25. package/dist/weather/weatherMath.js +4 -0
  26. package/dist/world/WorldHud.d.ts +6 -2
  27. package/dist/world/WorldHud.js +4 -4
  28. package/dist/world/worldBarSamples.d.ts +15 -3
  29. package/dist/world/worldBarSamples.js +34 -3
  30. package/package.json +4 -4
  31. package/skills/jgengine/api.md +15 -3
  32. package/skills/jgengine/capabilities.md +12 -0
  33. package/skills/jgengine-ui/api.md +33 -12
  34. package/skills/jgengine-ui/capabilities.md +12 -0
  35. package/skills/jgengine-world/SKILL.md +5 -0
  36. package/skills/jgengine-world/api.md +40 -2
  37. package/skills/jgengine-world/capabilities.md +16 -0
  38. package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md CHANGED
@@ -28,6 +28,49 @@ At publish, rename this heading to the new version and mirror the entries into
28
28
 
29
29
  ### Migrate
30
30
 
31
+ ### Changed
32
+
33
+ ### Added
34
+
35
+ ### Fixed
36
+
37
+ ## 0.16.0
38
+
39
+ ### Migrate
40
+
41
+ - **Bump lockstep SDK packages to `^0.16.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.
42
+ - **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
43
+
44
+ ### Changed
45
+
46
+ - **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
47
+
48
+ ### Added
49
+
50
+ - **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
51
+ - **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
52
+
53
+ - **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
54
+
55
+ - **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
56
+ - **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
57
+ - **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
58
+ - **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
59
+ - **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
60
+ - **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
61
+ - **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
62
+ - **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
63
+
64
+ ### Fixed
65
+
66
+ - **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
67
+ - **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
68
+
69
+ ## 0.15.0
70
+
71
+ ### Migrate
72
+
73
+ - **Bump lockstep SDK packages to `^0.15.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.12.0`; `@jgengine/github` is `0.2.0`.
31
74
  - **`jgengine create` scaffolds a textured world by default (#1550).** `--world` is now the default and `--no-world` is the opt-out (matching `--no-editor`); `--world` is still accepted. The default scaffold previously emitted untextured proxy geometry, which the AGENTS.md it ships declares a failing result. `create` now also pulls the starter asset packs into `public/models` after install, and never fails the scaffold if that pull cannot run (offline, blocked proxy, `--no-install`) — it prints `npx assets pull starter` in next steps instead, and the emitted AGENTS.md documents the same command. Skip the pull explicitly with `--no-assets`.
32
75
  - **Start game code at `@jgengine/shell/gameKit` (#1541).** Prefer that surface for `defineGame`, `GameHost`, stores, systems, authored-scene helpers, and HUD building blocks. `@jgengine/core/authoring` remains for pure core helpers the kit does not re-export but is no longer a competing start-here path.
33
76
  - **Every game must now declare its run-phase story — silence is a gate error (#1337).** The run-phase
@@ -73,10 +116,16 @@ At publish, rename this heading to the new version and mirror the entries into
73
116
 
74
117
  ### Added
75
118
 
119
+ - **`advanceStepCadence` (`@jgengine/core/audio/stepCadence`) — distance-driven footstep timing.** Pure state-in/state-out: feed it the ground distance a mover covered this frame and it reports how many steps landed and which foot led, so foley stays locked to distance instead of a wall-clock timer that desyncs the moment speed changes. Configurable stride, per-advance `strideScale` for crouch/sprint, and a shorter first step from rest so walking off is heard immediately.
120
+ - **`resolveMusicState` (`@jgengine/core/audio/musicState`) — combat-reactive soundtrack selection.** The engine could already crossfade themes but nothing decided *when*, so every game re-handrolled the threshold and got a track that flapped as one enemy walked in and out of range. Give it a tier ladder (intensity → theme) and it resolves the theme to play: escalation is immediate, de-escalation waits for the tier's `holdMs` and needs intensity to fall a `release` margin below the tier it is leaving. Pure and serialisable.
121
+ - **Synth voices take a `sustain` (`ToneVoice`/`NoiseVoice`).** Seconds held at full gain before the decay. Default 0 preserves every existing patch. Without it there was no way to author a *sustained* sound at all: every voice decayed to silence inside its duration, so a patch rendered once and looped as an ambient bed pulsed audibly once per loop. Set `sustain` to `duration` for a flat wind, rain, or machine-hum bed that loops seamlessly.
122
+
76
123
  - **`ModelMaterialOverride.rim` — a fresnel rim light on any entity/object model.** `{ color, strength, power }` adds a bright edge where the surface turns away from the viewer, patched into the material's own shader so it shadows, fogs, and tonemaps with the body rather than floating in front of it. This is the standard fix for an actor that shares a value band with its ground: no albedo choice separates a body from terrain lit by the same key, but an edge does. Omit for no rim (default).
77
124
 
78
125
  ### Fixed
79
126
 
127
+ - **Noise voices longer than the shared noise buffer no longer go silent partway through.** The 1-second buffer is sampled from a random offset, so any `NoiseVoice` whose duration exceeded the remainder ran off the end and finished in silence — invisible on short impact bursts, fatal for anything sustained. The source now wraps; the voice still ends on time.
128
+
80
129
  - **`terrain({ detail: { material: { tint } } })` separates a ground texture's grain from its hue.** A detail material's colour map used to replace the terrain's authored colour outright, so the biome palette, macro sweeps, and slope-rock blend under it were painted over and a whole region flattened to the texture's single hue — despite the API documenting that it "blends with, never replaces". `tint` defaults to 1 (the existing behaviour, so no shipped game changes); drop it toward 0 to keep the authored colour and take only the map's light and shade.
81
130
  - **`sky({ gradientExponent, cloudiness })` are now authorable — and `sky()` no longer silently drops them.** `gradientExponent` controls how fast the gradient reaches `zenithColor` off the horizon (default 0.65, unchanged): below 1 pulls the zenith hue down into the frame, which is what an eye-level camera in flat terrain actually needs — at the default the sky reads as one horizon-coloured wash however saturated the authored zenith is. `cloudiness` (default 0.35, unchanged) is the procedural cloud-band opacity.
82
131
  - **A model that throws while rendering is now reported instead of vanishing (#1588).** `ModelFallbackBoundary` swapped in its fallback (usually nothing) and stayed silent: `probes.fallbacks` stayed empty, no warning was logged, and an entity simply disappeared. It now reports a new `renderError` fallback cause on every failed render and warns once with the model url and the caught error.
@@ -289,7 +289,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
289
289
  fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
290
290
  near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
291
291
  far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
292
- }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
292
+ }, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true }, style: { touchAction: "none" }, children: [backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { skyColor: lighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: lighting?.hemisphere?.groundColor ?? "#3d4a38", sunDirection: lighting?.directional?.[0]?.position, sunColor: lighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, lighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: lighting })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsxs(GameProvider, { context: ctx, children: [_jsx(CullingProvider, { config: playable.visibility, children: _jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson }) }), WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null, bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, _jsx(WorldItems, { config: playable.worldItem }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
293
293
  cameraDraggingRef.current = dragging;
294
294
  } }), _jsx(PointerProbe, { service: pointerService })] }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled }), _jsx(DevtoolsRendererProbe, {}), resolvedLook.postProcessing !== undefined && resolvedLook.postProcessing.enabled !== false ? (_jsx(PostProcessing, { config: resolvedLook.postProcessing, quality: graphics.quality })) : null] }), !poster &&
295
295
  !orientationGate &&
@@ -16,10 +16,13 @@ function realizeTone(ctx, out, voice, cueGain, at) {
16
16
  osc.frequency.setValueAtTime(voice.freq, t);
17
17
  if (voice.slideTo !== undefined)
18
18
  osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
19
+ const sustainEnd = Math.max(attack, voice.sustain ?? 0);
19
20
  const g = ctx.createGain();
20
21
  g.gain.setValueAtTime(0, t);
21
22
  g.gain.linearRampToValueAtTime(peak, t + attack);
22
- g.gain.exponentialRampToValueAtTime(0.001, t + voice.duration);
23
+ if (sustainEnd > attack)
24
+ g.gain.setValueAtTime(peak, t + sustainEnd);
25
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
23
26
  osc.connect(g).connect(out);
24
27
  osc.start(t);
25
28
  osc.stop(t + voice.duration + 0.05);
@@ -31,12 +34,19 @@ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
31
34
  const src = ctx.createBufferSource();
32
35
  src.buffer = noiseBuf;
33
36
  src.playbackRate.value = 0.8 + Math.random() * 0.4;
37
+ // The shared buffer is 1 s and playback starts at a random offset inside it, so any voice longer
38
+ // than the remainder used to run off the end and go silent early. Wrapping keeps it noise throughout;
39
+ // the `duration` argument to start() still ends the voice on time.
40
+ src.loop = true;
34
41
  const filter = ctx.createBiquadFilter();
35
42
  filter.type = voice.filterType ?? "lowpass";
36
43
  filter.frequency.value = voice.filterFreq;
44
+ const sustain = Math.max(0, voice.sustain ?? 0);
37
45
  const g = ctx.createGain();
38
46
  g.gain.setValueAtTime(peak, t);
39
- g.gain.exponentialRampToValueAtTime(0.001, t + voice.duration * decay);
47
+ if (sustain > 0)
48
+ g.gain.setValueAtTime(peak, t + sustain);
49
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
40
50
  src.connect(filter).connect(g).connect(out);
41
51
  src.start(t, Math.random() * 0.5, voice.duration);
42
52
  }
@@ -147,7 +147,7 @@ function Structures({ structures, field }) {
147
147
  ...(building.rotationY === undefined ? {} : { rotationY: building.rotationY, pivot: building.center }),
148
148
  })), [structures, field]);
149
149
  const palette = useMemo(() => resolveBuildingPalette(structures.style, structures.palette), [structures.style, structures.palette]);
150
- return _jsx(InstancedBuildings, { buildings: placements, palette: palette });
150
+ return (_jsx(InstancedBuildings, { buildings: placements, palette: palette, ...(structures.kit === undefined ? {} : { kit: structures.kit }) }));
151
151
  }
152
152
  export function EnvironmentScene({ feature }) {
153
153
  const field = useMemo(() => resolveEnvironmentField(feature), [feature]);
@@ -7,6 +7,7 @@ export interface ResolvedWorldOverlayBars {
7
7
  maxDistance?: number;
8
8
  /** Nameplates only: draw the built-in HP bar. `false` yields a name-only plate. Defaults to `true`. */
9
9
  showHealth?: boolean;
10
+ occlude?: boolean;
10
11
  }
11
12
  /**
12
13
  * Normalize `worldHealthBars` / `nameplates` (`boolean | object | undefined`) into a
@@ -20,6 +21,7 @@ export declare function resolveWorldOverlayBars(config: boolean | {
20
21
  roles?: readonly CatalogEntityRole[];
21
22
  maxDistance?: number;
22
23
  showHealth?: boolean;
24
+ occlude?: boolean;
23
25
  } | undefined): ResolvedWorldOverlayBars | null;
24
26
  /** Resolved combat presentation channels (each boolean is whether that stack piece mounts). @internal */
25
27
  export interface ResolvedPresentationEffects {
@@ -15,6 +15,7 @@ export function resolveWorldOverlayBars(config) {
15
15
  ...(config.roles === undefined ? {} : { roles: config.roles }),
16
16
  ...(config.maxDistance === undefined ? {} : { maxDistance: config.maxDistance }),
17
17
  ...(config.showHealth === undefined ? {} : { showHealth: config.showHealth }),
18
+ ...(config.occlude === undefined ? {} : { occlude: config.occlude }),
18
19
  };
19
20
  }
20
21
  // Every channel defaults on EXCEPT tracers. `fireProjectile` is a generic seam (bullets,
@@ -44,6 +44,22 @@ export declare function handleModelLoadFailure(resolvedUrl: string, originalEven
44
44
  probe?: typeof probeModelUrl;
45
45
  warn?: (message: string) => void;
46
46
  }): Promise<void>;
47
+ /**
48
+ * Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in
49
+ * load order.
50
+ *
51
+ * A placeholder is already a console warning, but a warning does not fail anything — a shipped game
52
+ * can render one in its starting area and nobody finds out until someone looks at a screenshot.
53
+ * Reading this turns "the loader knows the asset is bad" into an assertable fact: a capture host or
54
+ * a smoke test can require it to be empty.
55
+ * @capability model-fallbacks models the loader replaced with a placeholder, for capture and smoke assertions
56
+ */
57
+ export declare function modelLoadFallbacks(): readonly {
58
+ url: string;
59
+ message: string;
60
+ }[];
61
+ /** Drops the recorded placeholder list. For tests and between capture runs. @internal */
62
+ export declare function clearModelLoadFallbacks(): void;
47
63
  /**
48
64
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
49
65
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -56,5 +72,13 @@ export declare function handleModelLoadFailure(resolvedUrl: string, originalEven
56
72
  export declare class DiagnosticGLTFLoader extends GLTFLoader {
57
73
  load(url: string, onLoad: (gltf: GLTF) => void, onProgress?: (event: ProgressEvent) => void, onError?: (event: unknown) => void): void;
58
74
  }
75
+ /**
76
+ * How long the shared GLB loader has been idle, in ms — `0` while any model is still
77
+ * in flight. A capture host reads this to wait for streaming to finish instead of
78
+ * guessing a settle delay: models that pop in after the shot are why an establishing
79
+ * capture used to come back half-empty until someone hand-tuned `--settle`.
80
+ * @capability model-load-idle how long the shared GLB loader has been idle, for capture settle waits
81
+ */
82
+ export declare function modelLoadIdleMs(): number;
59
83
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
60
84
  export declare const sharedGltfLoader: DiagnosticGLTFLoader;
@@ -117,8 +117,27 @@ export async function handleModelLoadFailure(resolvedUrl, originalEvent, onLoad,
117
117
  return;
118
118
  }
119
119
  (overrides.warn ?? warnMissingModel)(diagnosis.message);
120
+ servedFallbacks.push({ url: resolvedUrl, message: diagnosis.message });
120
121
  onLoad(createFallbackModel(diagnosis));
121
122
  }
123
+ const servedFallbacks = [];
124
+ /**
125
+ * Every model the shared loader diagnosed as broken and replaced with a magenta placeholder, in
126
+ * load order.
127
+ *
128
+ * A placeholder is already a console warning, but a warning does not fail anything — a shipped game
129
+ * can render one in its starting area and nobody finds out until someone looks at a screenshot.
130
+ * Reading this turns "the loader knows the asset is bad" into an assertable fact: a capture host or
131
+ * a smoke test can require it to be empty.
132
+ * @capability model-fallbacks models the loader replaced with a placeholder, for capture and smoke assertions
133
+ */
134
+ export function modelLoadFallbacks() {
135
+ return servedFallbacks;
136
+ }
137
+ /** Drops the recorded placeholder list. For tests and between capture runs. @internal */
138
+ export function clearModelLoadFallbacks() {
139
+ servedFallbacks.length = 0;
140
+ }
122
141
  /**
123
142
  * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
124
143
  * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
@@ -138,6 +157,33 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
138
157
  });
139
158
  }
140
159
  }
160
+ // LoadingManager reports queue transitions, not per-item events: `onStart` fires when the
161
+ // queue goes busy and `onLoad` when it drains (errors included, via itemEnd), so a flag plus
162
+ // a drain timestamp is the whole state.
163
+ let modelQueueBusy = false;
164
+ let modelQueueDrainedAt = 0;
165
+ const now = () => (typeof performance === "undefined" ? Date.now() : performance.now());
166
+ modelLoadingManager.onStart = () => {
167
+ modelQueueBusy = true;
168
+ };
169
+ modelLoadingManager.onLoad = () => {
170
+ modelQueueBusy = false;
171
+ modelQueueDrainedAt = now();
172
+ };
173
+ /**
174
+ * How long the shared GLB loader has been idle, in ms — `0` while any model is still
175
+ * in flight. A capture host reads this to wait for streaming to finish instead of
176
+ * guessing a settle delay: models that pop in after the shot are why an establishing
177
+ * capture used to come back half-empty until someone hand-tuned `--settle`.
178
+ * @capability model-load-idle how long the shared GLB loader has been idle, for capture settle waits
179
+ */
180
+ export function modelLoadIdleMs() {
181
+ if (modelQueueBusy)
182
+ return 0;
183
+ if (modelQueueDrainedAt === 0)
184
+ return Number.POSITIVE_INFINITY;
185
+ return now() - modelQueueDrainedAt;
186
+ }
141
187
  /** Shared GLTF loader used by shell entity/object model mounts. @internal */
142
188
  export const sharedGltfLoader = new DiagnosticGLTFLoader(modelLoadingManager);
143
189
  sharedGltfLoader.setMeshoptDecoder(MeshoptDecoder);
@@ -22,6 +22,8 @@ import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/ci
22
22
  import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
23
23
  import { generateBuilding, resolveBuildingPalette } from "@jgengine/core/world/buildings";
24
24
  import { buildScatterProxy } from "../scatter/scatterProxies.js";
25
+ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
26
+ import { getBuildingKit } from "../structures/buildingKitRegistry.js";
25
27
  import { createCityWindowMaterial } from "./cityWindowMaterial.js";
26
28
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
27
29
  const ASPHALT_COLOR = "#3d3f45";
@@ -323,58 +325,25 @@ function roleColor(palette, role) {
323
325
  const DETAIL_RADIUS = 95;
324
326
  const DETAIL_MAX = 12;
325
327
  const DETAIL_CLASSES = new Set(["tower", "slab", "shop", "rowhouse"]);
326
- /** Per-part-kind instanced batches for the near-LOD facade buildings (full lot yaw support). */
327
- function DetailBuildings({ buildings, palette }) {
328
- const meshes = useMemo(() => {
329
- if (buildings.length === 0)
330
- return null;
331
- const buckets = new Map();
332
- const partMatrix = new THREE.Matrix4();
333
- const compose = new THREE.Matrix4();
334
- for (const building of buildings) {
335
- for (const part of building.parts) {
336
- partMatrix.compose(new THREE.Vector3(part.position[0], part.position[1], part.position[2]), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), part.rotationY), new THREE.Vector3(Math.max(0.01, part.scale[0]), Math.max(0.01, part.scale[1]), Math.max(0.01, part.scale[2])));
337
- compose.multiplyMatrices(building.matrix, partMatrix);
338
- const bucket = buckets.get(part.kind);
339
- if (bucket === undefined)
340
- buckets.set(part.kind, [compose.clone()]);
341
- else
342
- bucket.push(compose.clone());
343
- }
344
- }
345
- const geometry = new THREE.BoxGeometry(1, 1, 1);
346
- const out = [];
347
- // Rooftop mechanical units read as debug cubes in the district's bright palette accent — force a
348
- // muted metal grey so they look like real HVAC/plant, never placeholder geometry.
349
- const ROOF_PROP_GREY = "#9a9ea3";
350
- for (const [kind, matrices] of buckets) {
351
- const color = kind === "roofProp" ? ROOF_PROP_GREY : palette[kind];
352
- const material = kind === "window" || kind === "storefront"
353
- ? new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 })
354
- : kind === "storeSign"
355
- ? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 })
356
- : new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
357
- const mesh = new THREE.InstancedMesh(geometry, material, matrices.length);
358
- matrices.forEach((matrix, i) => mesh.setMatrixAt(i, matrix));
359
- mesh.instanceMatrix.needsUpdate = true;
360
- mesh.castShadow = true;
361
- mesh.receiveShadow = true;
362
- mesh.frustumCulled = false;
363
- out.push(mesh);
364
- }
365
- return out;
366
- }, [buildings, palette]);
367
- useEffect(() => () => {
368
- if (meshes === null)
369
- return;
370
- for (const mesh of meshes) {
371
- mesh.geometry.dispose();
372
- mesh.material.dispose();
373
- }
374
- }, [meshes]);
375
- if (meshes === null)
328
+ // Rooftop mechanical units read as debug cubes in the district's bright palette accent — force a
329
+ // muted metal grey so they look like real HVAC/plant, never placeholder geometry.
330
+ const ROOF_PROP_GREY = "#9a9ea3";
331
+ /**
332
+ * Near-LOD facade buildings, rendered through the shared building path so an authored city picks up
333
+ * a `BuildingKit` exactly like a `building()` feature does: kit-bound parts instance their models,
334
+ * everything else keeps the palette blocks.
335
+ */
336
+ function DetailBuildings({ buildings, palette, kitName, }) {
337
+ const placements = useMemo(() => buildings.map((entry) => ({
338
+ building: { id: entry.lotId, parts: entry.parts },
339
+ position: [entry.center[0], entry.baseY, entry.center[1]],
340
+ rotationY: entry.rotationY,
341
+ })), [buildings]);
342
+ const detailPalette = useMemo(() => ({ ...palette, roofProp: ROOF_PROP_GREY }), [palette]);
343
+ const kit = getBuildingKit(kitName);
344
+ if (placements.length === 0)
376
345
  return null;
377
- return (_jsx(_Fragment, { children: meshes.map((mesh, i) => (_jsx("primitive", { object: mesh }, `detail:${i}`))) }));
346
+ return _jsx(InstancedBuildings, { buildings: placements, palette: detailPalette, ...(kit === undefined ? {} : { kit }) });
378
347
  }
379
348
  /** One authored city volume → the full merged/instanced district. */
380
349
  function OneCity({ object, context }) {
@@ -449,9 +418,14 @@ function OneCity({ object, context }) {
449
418
  bayWidth: lot.size[0] / baysWide,
450
419
  floorHeight: resolved.rules.floorHeight,
451
420
  });
452
- const matrix = new THREE.Matrix4().compose(new THREE.Vector3(lot.center[0], maxY, lot.center[1]), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), lot.rotationY), new THREE.Vector3(1, 1, 1));
453
421
  ids.add(lot.id);
454
- buildings.push({ lotId: lot.id, matrix, parts: building.parts });
422
+ buildings.push({
423
+ lotId: lot.id,
424
+ center: lot.center,
425
+ baseY: maxY,
426
+ rotationY: lot.rotationY,
427
+ parts: building.parts,
428
+ });
455
429
  }
456
430
  return { ids, buildings };
457
431
  }, [resolved, detailKey, sample]);
@@ -1072,7 +1046,7 @@ function OneCity({ object, context }) {
1072
1046
  }, [furniture]);
1073
1047
  if (resolved === null)
1074
1048
  return null;
1075
- return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette })] }));
1049
+ return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1076
1050
  }
1077
1051
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1078
1052
  function InstancedBoxes({ matrices, color }) {
@@ -0,0 +1,13 @@
1
+ import { type BuildingKitInstance } from "./buildingKitFit.js";
2
+ /** Props for {@link BuildingKitBatch}: one resolved model URL and every kit instance bound to it. @internal */
3
+ export interface BuildingKitBatchProps {
4
+ url: string;
5
+ instances: readonly BuildingKitInstance[];
6
+ }
7
+ /**
8
+ * GPU-instances one kit model across every building part bound to it: the GLB loads once, its meshes
9
+ * are harvested into instanceable draw sources, and each part's slot box drives the fit scale. Suspends
10
+ * while the GLB loads — mount it inside a `<Suspense>` so one slow model never blanks the rest.
11
+ * @internal
12
+ */
13
+ export declare function BuildingKitBatch({ url, instances }: BuildingKitBatchProps): import("react").JSX.Element;
@@ -0,0 +1,78 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { useLoader } from "@react-three/fiber";
4
+ import * as THREE from "three";
5
+ import { sharedGltfLoader } from "../render/modelLoad.js";
6
+ import { buildScatterModelSources, disposeScatterModelSources, } from "../scatter/scatterModels.js";
7
+ import { composeBuildingKitMatrix, measureBuildingKitModel, } from "./buildingKitFit.js";
8
+ function KitSourceInstances({ source, material, matrices, }) {
9
+ const meshRef = useRef(null);
10
+ useEffect(() => {
11
+ const mesh = meshRef.current;
12
+ if (mesh === null)
13
+ return;
14
+ const composed = new THREE.Matrix4();
15
+ for (let i = 0; i < matrices.length; i += 1) {
16
+ composed.multiplyMatrices(matrices[i], source.localMatrix);
17
+ mesh.setMatrixAt(i, composed);
18
+ }
19
+ mesh.count = matrices.length;
20
+ mesh.instanceMatrix.needsUpdate = true;
21
+ mesh.computeBoundingSphere();
22
+ }, [matrices, source]);
23
+ return (_jsx("instancedMesh", { ref: meshRef, args: [source.geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
24
+ }
25
+ function tintOne(material, color) {
26
+ const clone = material.clone();
27
+ const tintable = clone;
28
+ if (tintable.color !== undefined)
29
+ tintable.color.copy(color);
30
+ return clone;
31
+ }
32
+ function tintMaterial(material, tint) {
33
+ const color = new THREE.Color(tint);
34
+ return Array.isArray(material) ? material.map((entry) => tintOne(entry, color)) : tintOne(material, color);
35
+ }
36
+ function disposeMaterial(material) {
37
+ if (Array.isArray(material))
38
+ for (const entry of material)
39
+ entry.dispose();
40
+ else
41
+ material.dispose();
42
+ }
43
+ /**
44
+ * GPU-instances one kit model across every building part bound to it: the GLB loads once, its meshes
45
+ * are harvested into instanceable draw sources, and each part's slot box drives the fit scale. Suspends
46
+ * while the GLB loads — mount it inside a `<Suspense>` so one slow model never blanks the rest.
47
+ * @internal
48
+ */
49
+ export function BuildingKitBatch({ url, instances }) {
50
+ const gltf = useLoader(sharedGltfLoader, url);
51
+ const { sources, root } = useMemo(() => buildScatterModelSources(gltf.scene, { url }), [gltf, url]);
52
+ useEffect(() => () => disposeScatterModelSources(root), [root]);
53
+ const bounds = useMemo(() => measureBuildingKitModel(root), [root]);
54
+ const groups = useMemo(() => {
55
+ const byTint = new Map();
56
+ for (const instance of instances) {
57
+ const matrix = composeBuildingKitMatrix(instance, bounds, new THREE.Matrix4());
58
+ const tint = instance.part.tint ?? "";
59
+ const bucket = byTint.get(tint);
60
+ if (bucket === undefined)
61
+ byTint.set(tint, [matrix]);
62
+ else
63
+ bucket.push(matrix);
64
+ }
65
+ return [...byTint.entries()];
66
+ }, [instances, bounds]);
67
+ const tinted = useMemo(() => {
68
+ const map = new Map();
69
+ for (const [tint] of groups) {
70
+ if (tint === "")
71
+ continue;
72
+ map.set(tint, sources.map((source) => tintMaterial(source.material, tint)));
73
+ }
74
+ return map;
75
+ }, [groups, sources]);
76
+ useEffect(() => () => tinted.forEach((list) => list.forEach(disposeMaterial)), [tinted]);
77
+ return (_jsx(_Fragment, { children: groups.map(([tint, matrices]) => sources.map((source, index) => (_jsx(KitSourceInstances, { source: source, material: tinted.get(tint)?.[index] ?? source.material, matrices: matrices }, `${tint}:${index}`)))) }));
78
+ }
@@ -1,4 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { BuildingKitSlot } from "@jgengine/core/world/buildings";
3
+ import type { BuildingKit } from "@jgengine/core/world/buildingKit";
2
4
  export type BuildingFacade = "front" | "back" | "left" | "right" | "roof";
3
5
  export type BuildingPartKind = "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner";
4
6
  export interface BuildingPartPlacement {
@@ -8,6 +10,8 @@ export interface BuildingPartPlacement {
8
10
  position: readonly [number, number, number];
9
11
  rotationY: number;
10
12
  scale: readonly [number, number, number];
13
+ /** Which kit variant this slot asks for; emitted by the generator, consumed by `BuildingKit` binding. */
14
+ kit?: BuildingKitSlot;
11
15
  }
12
16
  export interface GeneratedBuildingData {
13
17
  id: string;
@@ -37,7 +41,12 @@ export interface BuildingBlockProps {
37
41
  export interface GeneratedBuildingProps {
38
42
  building: GeneratedBuildingData;
39
43
  palette?: BuildingMaterialPalette;
40
- kit?: BuildingKitRenderer;
44
+ /** Per-part React escape hatch, tried before the kit; return undefined to fall through. */
45
+ partRenderer?: BuildingKitRenderer;
46
+ /** Binds part kinds to instanced GLB models; unbound kinds keep the palette boxes. */
47
+ kit?: BuildingKit;
48
+ /** Maps a kit part's `model` reference to a loadable URL. Defaults to identity. */
49
+ resolveModelUrl?: (model: string) => string;
41
50
  visibleKinds?: readonly BuildingPartKind[];
42
51
  }
43
52
  export interface InstancedBuildingPlacement {
@@ -51,8 +60,15 @@ export interface InstancedBuildingPlacement {
51
60
  export interface InstancedBuildingsProps {
52
61
  buildings: readonly InstancedBuildingPlacement[];
53
62
  palette?: BuildingMaterialPalette;
63
+ /**
64
+ * Binds part kinds to instanced GLB models. Kinds the kit leaves unbound keep rendering the palette
65
+ * boxes, and kinds it lists in `omit` render nothing; omit the prop for today's all-box massing.
66
+ */
67
+ kit?: BuildingKit;
68
+ /** Maps a kit part's `model` reference to a loadable URL. Defaults to identity. */
69
+ resolveModelUrl?: (model: string) => string;
54
70
  visibleKinds?: readonly BuildingPartKind[];
55
71
  }
56
- export declare function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): import("react").JSX.Element | null;
72
+ export declare function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }: InstancedBuildingsProps): import("react").JSX.Element | null;
57
73
  export declare function BuildingBlock({ part, palette }: BuildingBlockProps): import("react").JSX.Element;
58
- export declare function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): import("react").JSX.Element;
74
+ export declare function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }: GeneratedBuildingProps): import("react").JSX.Element;