@jgengine/shell 0.15.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.
- package/CHANGELOG.md +32 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/presentationResolve.d.ts +2 -0
- package/dist/presentationResolve.js +1 -0
- package/dist/render/modelLoad.d.ts +24 -0
- package/dist/render/modelLoad.js +46 -0
- package/dist/scene/CityRenderer.js +28 -54
- package/dist/structures/BuildingKitBatch.d.ts +13 -0
- package/dist/structures/BuildingKitBatch.js +78 -0
- package/dist/structures/GeneratedBuilding.d.ts +19 -3
- package/dist/structures/GeneratedBuilding.js +13 -81
- package/dist/structures/buildingKitFit.d.ts +59 -0
- package/dist/structures/buildingKitFit.js +188 -0
- package/dist/structures/buildingKitRegistry.d.ts +9 -0
- package/dist/structures/buildingKitRegistry.js +26 -0
- package/dist/weather/DustField.d.ts +41 -0
- package/dist/weather/DustField.js +129 -0
- package/dist/weather/WeatherLayer.d.ts +8 -2
- package/dist/weather/WeatherLayer.js +5 -2
- package/dist/weather/index.d.ts +1 -0
- package/dist/weather/index.js +1 -0
- package/dist/weather/weatherMath.d.ts +4 -0
- package/dist/weather/weatherMath.js +4 -0
- package/dist/world/WorldHud.d.ts +6 -2
- package/dist/world/WorldHud.js +4 -4
- package/dist/world/worldBarSamples.d.ts +15 -3
- package/dist/world/worldBarSamples.js +34 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +15 -3
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-ui/api.md +33 -12
- package/skills/jgengine-ui/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +21 -2
- package/skills/jgengine-world/capabilities.md +16 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,38 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
36
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
|
+
|
|
37
69
|
## 0.15.0
|
|
38
70
|
|
|
39
71
|
### 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;
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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({
|
|
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
|
|
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;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useLayoutEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { Suspense, useLayoutEffect, useMemo, useRef } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { useDisposable } from "../render/useDisposable.js";
|
|
5
|
+
import { BuildingKitBatch } from "./BuildingKitBatch.js";
|
|
6
|
+
import { bucketBuildingParts, normalFor, outwardOffset } from "./buildingKitFit.js";
|
|
5
7
|
const DEFAULT_PALETTE = {
|
|
6
8
|
wall: "#83766a",
|
|
7
9
|
window: "#8ecae6",
|
|
@@ -19,26 +21,6 @@ const DEFAULT_PALETTE = {
|
|
|
19
21
|
function colorFor(kind, palette) {
|
|
20
22
|
return palette?.[kind] ?? DEFAULT_PALETTE[kind];
|
|
21
23
|
}
|
|
22
|
-
function normalFor(facade) {
|
|
23
|
-
if (facade === "front")
|
|
24
|
-
return [0, 1];
|
|
25
|
-
if (facade === "back")
|
|
26
|
-
return [0, -1];
|
|
27
|
-
if (facade === "left")
|
|
28
|
-
return [-1, 0];
|
|
29
|
-
if (facade === "right")
|
|
30
|
-
return [1, 0];
|
|
31
|
-
return [0, 0];
|
|
32
|
-
}
|
|
33
|
-
function outwardOffset(part) {
|
|
34
|
-
if (part.kind === "wall" || part.kind === "roof" || part.kind === "corner")
|
|
35
|
-
return 0;
|
|
36
|
-
if (part.kind === "window" || part.kind === "shutter" || part.kind === "storefront")
|
|
37
|
-
return 0.025;
|
|
38
|
-
if (part.kind === "awning" || part.kind === "storeSign")
|
|
39
|
-
return 0.09;
|
|
40
|
-
return 0.12;
|
|
41
|
-
}
|
|
42
24
|
function materialFor(part, palette) {
|
|
43
25
|
const color = colorFor(part.kind, palette);
|
|
44
26
|
if (part.kind === "window" || part.kind === "storefront") {
|
|
@@ -65,56 +47,6 @@ function batchMaterialFor(kind, palette) {
|
|
|
65
47
|
}
|
|
66
48
|
return new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
|
|
67
49
|
}
|
|
68
|
-
const CLOTHESLINE_ROW_OFFSETS = [-0.09, 0.09];
|
|
69
|
-
function bucketPartMatrices(buildings, visible) {
|
|
70
|
-
const dummy = new THREE.Object3D();
|
|
71
|
-
const buckets = new Map();
|
|
72
|
-
for (const placement of buildings) {
|
|
73
|
-
const [ox, oy, oz] = placement.position ?? [0, 0, 0];
|
|
74
|
-
// Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
|
|
75
|
-
// dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
|
|
76
|
-
const yaw = placement.rotationY ?? 0;
|
|
77
|
-
const [px0, pz0] = placement.pivot ?? [0, 0];
|
|
78
|
-
const cos = Math.cos(yaw);
|
|
79
|
-
const sin = Math.sin(yaw);
|
|
80
|
-
for (const part of placement.building.parts) {
|
|
81
|
-
if (visible !== null && !visible.has(part.kind))
|
|
82
|
-
continue;
|
|
83
|
-
const [nx, nz] = normalFor(part.facade);
|
|
84
|
-
const offset = outwardOffset(part);
|
|
85
|
-
// Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
|
|
86
|
-
const lx = part.position[0] - px0 + nx * offset;
|
|
87
|
-
const lz = part.position[2] - pz0 + nz * offset;
|
|
88
|
-
const px = ox + px0 + lx * cos + lz * sin;
|
|
89
|
-
const py = oy + part.position[1];
|
|
90
|
-
const pz = oz + pz0 - lx * sin + lz * cos;
|
|
91
|
-
const partYaw = part.rotationY + yaw;
|
|
92
|
-
let bucket = buckets.get(part.kind);
|
|
93
|
-
if (bucket === undefined) {
|
|
94
|
-
bucket = [];
|
|
95
|
-
buckets.set(part.kind, bucket);
|
|
96
|
-
}
|
|
97
|
-
if (part.kind === "clothesline") {
|
|
98
|
-
const rowSin = Math.sin(partYaw);
|
|
99
|
-
const rowCos = Math.cos(partYaw);
|
|
100
|
-
for (const row of CLOTHESLINE_ROW_OFFSETS) {
|
|
101
|
-
dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
|
|
102
|
-
dummy.rotation.set(0, partYaw, 0);
|
|
103
|
-
dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
|
|
104
|
-
dummy.updateMatrix();
|
|
105
|
-
bucket.push(dummy.matrix.clone());
|
|
106
|
-
}
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
dummy.position.set(px, py, pz);
|
|
110
|
-
dummy.rotation.set(0, partYaw, 0);
|
|
111
|
-
dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
|
|
112
|
-
dummy.updateMatrix();
|
|
113
|
-
bucket.push(dummy.matrix.clone());
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return buckets;
|
|
117
|
-
}
|
|
118
50
|
function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
|
|
119
51
|
const meshRef = useRef(null);
|
|
120
52
|
const material = useDisposable(() => batchMaterialFor(kind, palette), [kind, palette]);
|
|
@@ -128,15 +60,15 @@ function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
|
|
|
128
60
|
}, [matrices, material]);
|
|
129
61
|
return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
|
|
130
62
|
}
|
|
131
|
-
export function InstancedBuildings({ buildings, palette, visibleKinds }) {
|
|
63
|
+
export function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }) {
|
|
132
64
|
const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
|
|
133
|
-
const
|
|
65
|
+
const { boxes, models } = useMemo(() => {
|
|
134
66
|
const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
|
|
135
|
-
return
|
|
136
|
-
}, [buildings, visibleKinds]);
|
|
137
|
-
if (
|
|
67
|
+
return bucketBuildingParts(buildings, visible, kit, resolveModelUrl);
|
|
68
|
+
}, [buildings, visibleKinds, kit, resolveModelUrl]);
|
|
69
|
+
if (boxes.size === 0 && models.size === 0)
|
|
138
70
|
return null;
|
|
139
|
-
return (
|
|
71
|
+
return (_jsxs("group", { children: [[...boxes.entries()].map(([kind, matrices]) => (_jsx(BuildingKindBatch, { kind: kind, matrices: matrices, palette: palette, geometry: geometry }, kind))), [...models.entries()].map(([url, instances]) => (_jsx(Suspense, { fallback: null, children: _jsx(BuildingKitBatch, { url: url, instances: instances }) }, url)))] }));
|
|
140
72
|
}
|
|
141
73
|
function BlockMesh({ part, palette }) {
|
|
142
74
|
const [nx, nz] = normalFor(part.facade);
|
|
@@ -161,7 +93,7 @@ export function BuildingBlock({ part, palette }) {
|
|
|
161
93
|
}
|
|
162
94
|
return _jsx(BlockMesh, { part: part, palette: palette });
|
|
163
95
|
}
|
|
164
|
-
export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
|
|
96
|
+
export function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }) {
|
|
165
97
|
const { kitParts, batched } = useMemo(() => {
|
|
166
98
|
const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
|
|
167
99
|
const kitRendered = [];
|
|
@@ -169,7 +101,7 @@ export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
|
|
|
169
101
|
for (const part of building.parts) {
|
|
170
102
|
if (visible !== null && !visible.has(part.kind))
|
|
171
103
|
continue;
|
|
172
|
-
const rendered =
|
|
104
|
+
const rendered = partRenderer?.renderPart?.(part);
|
|
173
105
|
if (rendered !== undefined) {
|
|
174
106
|
kitRendered.push({ id: part.id, node: rendered });
|
|
175
107
|
continue;
|
|
@@ -178,6 +110,6 @@ export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
|
|
|
178
110
|
}
|
|
179
111
|
const placements = batchedParts.length === 0 ? [] : [{ building: { id: building.id, parts: batchedParts } }];
|
|
180
112
|
return { kitParts: kitRendered, batched: placements };
|
|
181
|
-
}, [building,
|
|
182
|
-
return (_jsxs("group", { name: building.id, children: [kitParts.map((entry) => (_jsx("group", { children: entry.node }, entry.id))), _jsx(InstancedBuildings, { buildings: batched, palette: palette })] }));
|
|
113
|
+
}, [building, partRenderer, visibleKinds]);
|
|
114
|
+
return (_jsxs("group", { name: building.id, children: [kitParts.map((entry) => (_jsx("group", { children: entry.node }, entry.id))), _jsx(InstancedBuildings, { buildings: batched, palette: palette, kit: kit, resolveModelUrl: resolveModelUrl })] }));
|
|
183
115
|
}
|