@jgengine/shell 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/Shell3dPresentation.js +1 -1
  3. package/dist/environment/EnvironmentScene.js +1 -1
  4. package/dist/presentationResolve.d.ts +2 -0
  5. package/dist/presentationResolve.js +1 -0
  6. package/dist/render/modelLoad.d.ts +24 -0
  7. package/dist/render/modelLoad.js +46 -0
  8. package/dist/scene/CityRenderer.js +28 -54
  9. package/dist/structures/BuildingKitBatch.d.ts +13 -0
  10. package/dist/structures/BuildingKitBatch.js +78 -0
  11. package/dist/structures/GeneratedBuilding.d.ts +19 -3
  12. package/dist/structures/GeneratedBuilding.js +13 -81
  13. package/dist/structures/buildingKitFit.d.ts +59 -0
  14. package/dist/structures/buildingKitFit.js +188 -0
  15. package/dist/structures/buildingKitRegistry.d.ts +9 -0
  16. package/dist/structures/buildingKitRegistry.js +26 -0
  17. package/dist/weather/DustField.d.ts +41 -0
  18. package/dist/weather/DustField.js +129 -0
  19. package/dist/weather/WeatherLayer.d.ts +8 -2
  20. package/dist/weather/WeatherLayer.js +5 -2
  21. package/dist/weather/index.d.ts +1 -0
  22. package/dist/weather/index.js +1 -0
  23. package/dist/weather/weatherMath.d.ts +4 -0
  24. package/dist/weather/weatherMath.js +4 -0
  25. package/dist/world/WorldHud.d.ts +6 -2
  26. package/dist/world/WorldHud.js +4 -4
  27. package/dist/world/worldBarSamples.d.ts +15 -3
  28. package/dist/world/worldBarSamples.js +34 -3
  29. package/package.json +4 -4
  30. package/skills/jgengine/api.md +38 -5
  31. package/skills/jgengine/capabilities.md +24 -0
  32. package/skills/jgengine-ui/api.md +33 -12
  33. package/skills/jgengine-ui/capabilities.md +12 -0
  34. package/skills/jgengine-world/SKILL.md +5 -0
  35. package/skills/jgengine-world/api.md +27 -2
  36. package/skills/jgengine-world/capabilities.md +24 -0
  37. package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md CHANGED
@@ -34,6 +34,68 @@ At publish, rename this heading to the new version and mirror the entries into
34
34
 
35
35
  ### Fixed
36
36
 
37
+ ## 0.17.0
38
+
39
+ ### Migrate
40
+
41
+ - **Bump lockstep SDK packages to `^0.17.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.
42
+ - **A deleted player profile stays deleted.** When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row — that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`"none"`, `"chunks"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.
43
+ - **`slotsPerServer` is capped at 256** (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.
44
+ - **The tick cron only sweeps games that declare `loop.onTick`.** A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.
45
+
46
+ ### Changed
47
+
48
+ - **Walking NPCs now stop at the geometry that stops the player.** `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders — the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).
49
+ - **`@jgengine/assets` verify fails a model source that contributes no index entries.** A model pack that has never been pulled is a pin nothing has ever checked — that is how a Street Pack archive sat in the catalogue titled "Downtown City MegaKit". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.
50
+ - **`persistServerSnapshot` returns `boolean` and skips a write that would change nothing.** Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.
51
+ - **`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot**, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.
52
+ - **`tickActiveServers` is bounded** to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.
53
+
54
+ ### Added
55
+
56
+ - **`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`)** — the engine's single answer to "what is solid here", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.
57
+ - **Bounded hydration and a client chunk read** — `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.
58
+ - **`@jgengine/core/runtime/worldChunks`** — `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.
59
+ - **Placed objects cull to `replication.aoiRadius`.** `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.
60
+ - **`createGameServerFunctions({ matchmaking: "singleton" })`** — one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.
61
+ - **`GameRuntime.hasTick`** reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.
62
+
63
+ ### Removed
64
+
65
+ - **Asset source `quaternius-downtown-city`.** It was titled "Downtown City MegaKit" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only — a pull found no models. Nothing referenced it.
66
+
67
+ ## 0.16.0
68
+
69
+ ### Migrate
70
+
71
+ - **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`.
72
+ - **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`.
73
+
74
+ ### Changed
75
+
76
+ - **`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`.
77
+
78
+ ### Added
79
+
80
+ - **`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.
81
+ - **`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.
82
+
83
+ - **`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.
84
+
85
+ - **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.
86
+ - **`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.
87
+ - **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
88
+ - **`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.
89
+ - **`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.
90
+ - **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`.
91
+ - **`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.
92
+ - **`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.
93
+
94
+ ### Fixed
95
+
96
+ - **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.
97
+ - **`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`.
98
+
37
99
  ## 0.15.0
38
100
 
39
101
  ### Migrate
@@ -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 &&
@@ -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;