@jgengine/assets 0.27.0 → 0.28.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 CHANGED
@@ -26,6 +26,136 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.28.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.28.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.25.0`; `@jgengine/github` is `0.15.0`.
34
+ - `groupStaticObjects` (`@jgengine/shell/world/staticObjectBatches`) takes an explicit `cell` argument (default `STATIC_BATCH_CELL`), and `STATIC_BATCH_CHUNK` is now `STATIC_BATCH_CELL`. `STATIC_BATCH_MIN` is 2. A caller that passed a chunk size to pick the grouping keeps working by passing it as the third argument.
35
+ - `createNearAgents` (`@jgengine/core/gameplay`) `snapshot()`/`restore()` now carry the crowd as well as the agents: `{ agents, crowd }` where `crowd` is a `NavCrowdSnapshot`. A restored near agent resumes the route it was walking instead of re-planning a fresh one, which is what makes an off-screen world round-trip tick-exactly.
36
+ - `OffScreenSnapshot.residents[].staleAt` is `number | null`; `null` is the "never goes stale" sentinel that was `Infinity`. `Infinity` does not survive JSON, so a saved off-screen world decayed a far, routine-less resident on every load.
37
+ - `TrafficSnapshot.counters` and `PoliceResponseState.counters` no longer carry `slowestTickMs`. A wall-clock timing sample is not state; it stays on `stats()` only, so a save document is byte-deterministic.
38
+ - `MaterialMaps.displacement` (`@jgengine/assets`) and `TerrainMaterialMaps.displacement` (`@jgengine/core/world/features`) are optional now. A caller that used to invent a displacement URL can drop it; nothing reads it.
39
+
40
+ ### Added
41
+
42
+ - `setModelSubMeshMerge` (`@jgengine/shell/render/modelMeshMerge`) turns load-time sub-mesh merging off for a game that must keep one node per source mesh.
43
+ - `render/staticBatches` devtools system probe reports the grouped batches, the instances behind them, the draws submitted and the distinct instances drawn after culling, so batching and per-instance culling are visible in `debug_snapshot().systems`. `countStaticBatchDraws` (`@jgengine/shell/world/staticObjectBatches`) reads those counts from a live scene.
44
+ - `freezeStaticTransforms` / `thawStaticTransforms` (`@jgengine/shell/render/staticTransforms`) keep authored scenery out of the per-frame matrix update.
45
+ - `ModelLodConfig` (`@jgengine/core/game/playableGame`) sets a character model's full-rate, half-rate and crowd distances plus the crowd hysteresis.
46
+ - `characterLodTier` / `characterLodPolicy` (`@jgengine/shell/render/characterLod`) resolve a distance to a tier, and the `render/characters` devtools system probe reports rigs by tier, skinned parts, bone pose writes per frame and crowd instances in `debug_snapshot().systems`.
47
+ - Load-time shader warmup for 3D presentations. `createShaderWarmup` (`@jgengine/shell/render/shaderWarmup`) compiles the mounted scene through `WebGLRenderer.compileAsync` in rounds, one per burst of streamed models, and reports a phase, round and program count a loading screen can render. It is bounded: a round is raced against its budget, so a driver that never reports a finished program cannot hold the first frame.
48
+ - `SceneWarmupCover` (`@jgengine/shell/render/SceneWarmupGate`) is the opaque cover the shell puts over the canvas until that warmup finishes. It carries `data-shell-code-loading`, so capture and drive already wait for it, and it clears itself on a failed compile. Game UI and menus mount above it and stay usable.
49
+ - `createPoliceResponse` (`@jgengine/core/world`) is the wanted level over the shared `ai/heatSystem` curve: a crime raises heat only while `sensor/perception` memory holds the suspect in view (the sighting's own `sourceId`/`sourceTick` kept as its owner), decay starts once every witness has been out of sight for `escapeSeconds`, and a caller-supplied tier table decides what police do — patrol, investigate, pursue, roadblock — with pursuer budgets, a last-sighting search area that grows while the trail is cold, and roadblock sites off the authored road data. `state()`/`restore()` round-trip mid-chase and the injected generator is the only source of variety.
50
+ - `policeRoadsFromLaneGraph` (`@jgengine/core/world`) reads cordon sites and an indexed nearby-lane query out of an authored lane graph: `traffic_roadblock` markers projected onto the lane they block, or the graph's intersections when none are authored.
51
+ - `DEFAULT_POLICE_TIERS` and `DEFAULT_POLICE_TUNING` are the starting tier table and curve a game overrides.
52
+ - `createRideSystem` (`@jgengine/core/gameplay`) runs a park's rides: each one cycles load → ride → unload at its own capacity, boards from its own line, wears against its own breakdown curve (chance per completed cycle, repair seconds and money) and reports boards, alights, breakdowns and repairs through a sink, so the game keeps the bodies, the visuals and the wallet. `snapshot()`/`restore()` round-trip a park mid-cycle, `retune(id, patch)` changes a ride's ratings, capacity, timings or breakdown curve in play, and `service(id)` pays the maintenance cost and clears wear.
53
+ - `createWaitQueue` (`@jgengine/core/gameplay`) is a real line: guests join the back, leave from anywhere, and the wait a new arrival faces comes from the queue's own measured boarding throughput rather than a configured constant. `DEFAULT_QUEUE_SEED_SERVICE_SECONDS` only bootstraps the estimate until two boardings have been observed.
54
+ - `createRideChooser`, `rideChoiceOptions`, `rideChoiceCandidates`, `rideQueueCostWeight` and `rideAppeal` (`@jgengine/core/gameplay`) turn a guest's `RideTaste` (intensity comfort, nausea comfort, queue tolerance) plus the park's ride data into `createUtilityBrain` options, each ride costed by the seconds its own line reports — ride choice is one more scored option, not a fixed order.
55
+ - `DEFAULT_RIDE_BREAKDOWN`, `DEFAULT_RIDE_RATING_RANGE` and `DEFAULT_RIDE_FUN_CURVE` are the starting curve and scales a park overrides.
56
+ - `createMissionRuntime` (`@jgengine/core/gameplay`) runs missions as data: a mission is a graph of objectives the machine walks, with named fail states, per-objective timers, conditional branches, per-objective and completion rewards, a `rollout` fact rolled from the injected RNG, and a `next` mission that chains a follow-up. `tick(dt, world)` advances the active mission against caller-supplied actors, counters, facts and survivors; `signal`/`fail` let an authored trigger complete or fail it. `snapshot()`/`restore()` round-trip a run, `stats()` and an optional `devtoolsName` probe expose it, and cost per tick is the active objective's own fail list plus one goal test.
57
+ - Objective kinds are the shared primitives `reach`, `interact`, `survive`, `pursue`, `kill` and `collect`; a fail condition can be `actor-died`, `timer` or `fact`.
58
+ - `escortMission`, `chaseMission` and `defendMission` (`@jgengine/core/gameplay`) are compositions of those kinds, not archetypes: an escort is a `reach` with an `actor-died` guard, a defend is a `survive` with a survivor floor that banks what is left standing as the score, and a chase is a `pursue` with an escape guard.
59
+ - `validateMissionCatalog` (`@jgengine/core/gameplay`) is the mission content gate: duplicate ids, unknown branch/next/timeout targets, unknown fail states and markers, per-kind required fields, unreachable objectives, and the counter-only rule — a mission with a completion path made entirely of `kill`/`collect` objectives is an error. `createMissionRuntime` runs it at construction, so a counter-only mission throws instead of shipping.
60
+ - `registerMissionTriggerActions`, `MISSION_TRIGGER_ACTIONS`, `createMissionTriggerBridge` and `missionMarkers` (`@jgengine/core/gameplay`) start, advance and fail a mission from authored scene markers using the shared `on`/`action` trigger vocabulary; positional objectives read marker positions off the authored document.
61
+ - `applyMissionRewards` (`@jgengine/core/gameplay`) pays a mission through the same appliers quests use (XP, currency, items, unlocks) and rolls named loot tables through the game's loot registry.
62
+ - `createMarket` (`@jgengine/core/gameplay`) prices goods from supply and demand over finite stock on a stated curve: `price = clamp(basePrice × (coverPeriods × demand / max(stock, 1))^elasticity)` between `basePrice × minFactor` and `basePrice × maxFactor`. `sell` decrements the finite stock and refuses a sale it cannot cover with the shortfall named, `restock` lowers the price, `quote`/`priceAfter` read the move without mutating, `recordDemand(unitsSold, unitsUnmet)` folds a period of observed buying into demand, and `priceCurve` replaces the curve. `retune` changes a good's policy in play; `snapshot()`/`restore()` round-trip stock and demand; money is integer minor units.
63
+ - `createTransactionLedger` (`@jgengine/core/gameplay`) keeps per-account, per-currency balances in integer minor units with a journal that records every movement, the balance it produced and the reason it happened. `post` is the one money path — sales, purchases, upkeep and loan payments all carry a caller-owned reason and an optional `ref` — and `totals()` keeps a per-reason count and net beyond the `maxEntries` journal cap. `addCharge` registers a recurring charge (upkeep, rent) settled on its cadence by `chargeDue`, bounded per charge and per advance; a cycle with no funds is counted as missed instead of overdrawing.
64
+ - `buildLoanSchedule` and `createLoanBook` (`@jgengine/core/gameplay`) draw a loan, post the principal, and settle interest (`"loan-interest"`) and principal (`"loan-principal"`) as separate reason-tagged entries. A schedule is built up front in integer minor units — `"amortized"` levels the payment on the outstanding balance, `"flat"` charges interest on the original principal — and the final payment absorbs the rounding, so the principal parts sum to the principal exactly. A payment that comes due with no funds stays in arrears and counts once per advance; after `maxMissed` the loan is defaulted and stops being advanced. `repay` pays principal early, and state is terms plus progress, so the schedule is rebuilt on restore.
65
+ - `createGameSave` (`@jgengine/core/gameplay`) is the one game save document: a `version`, the game/time/playtime metadata a save menu shows, the world's shared RNG seed and cursor, and one section per registered system. Register each stateful system once (`name` + `snapshot()`/`restore()`, optional `reset()`); `capture()` builds the document, `save()`/`touch()` persist it through the same injected `SaveBackend` `createSaveStore` uses, `load()`/`switchSlot()` restore every section and the RNG cursor, and `migrate` upgrades an older payload. Autosave cadence is caller policy, and `digest()`/`digests()` hash the whole state (or one section) for a save/reload determinism test.
66
+ - `gameSaveDigest` (`@jgengine/core/gameplay`) hashes a `GameSaveDocument`'s state — every section, the clock and the RNG cursor — excluding the wall-clock `savedAt`.
67
+ - `GameSaveConfig.devtoolsName` registers a devtools probe reporting the active slot, the save status, the change revision and the system count.
68
+ - `createVisitorPopulation` (`@jgengine/core/sim/population`) counts a crowd by its members: a visitor exists only once the game's `VisitorWorld.admit` has made its body and `VisitorWorld.has` confirms it, so `onSite()` is the member count and `arrivalsToday()` is the sum of the admissions the world accepted. `arrivalRate`, `capacity`, `shouldLeave` and `inside` are caller policy; `adopt` takes over a body a save or a seed made; `rollDay()` closes the day without emptying the park.
69
+ - `createDamageRuntime` (`@jgengine/core/combat/damageRuntime`) announces every damage application as `combat.damage` on the game's own event bus: `target`, `source`, `type`, `amount`, `absorbed`, `remaining`, `lethal`, plus `status` for a status tick. Pass `events: ctx.game.events` and `healthOf` (the target's live health, read before you apply it) so a HUD can name the blow and tell whether the target died; without `healthOf` the event reports `remaining: null` and `lethal: false`. `recentDamage(limit)` and the devtools probe line carry the same stream.
70
+ - `StatusDamageTick.source` (`@jgengine/core/combat/statusRuntime`) names who applied the status that is ticking, so a burn names its arsonist.
71
+ - `clearCameraBoom` (`@jgengine/shell/camera`) answers where a camera can sit without entering world collision: the desired position is rejected when the boom hits a surface, and the camera is placed one `clearance` before it. Pulling in is immediate, easing back out follows `releaseRate`, so a boom reads as a spring arm rather than a hard stop. `sceneCameraQuery(ctx.scene.raycast)` feeds it the existing world solids, object colliders and walls — no new collision layer — and any other hit query can be passed instead.
72
+ - `FirstPersonCameraConfig.collision` (`{ clearance?, armBack? } | false`) keeps the `first` rig's eye out of the trunk or wall the body can reach. On by default; `false` restores the unchecked camera.
73
+ - `createEffectSystem` (`@jgengine/core/combat/effects`) takes `onDamage?(application)`: one `EffectDamageApplication` per target a draining effect lands on, before `onLethal` despawns it. It carries `target`, `source` (the effect's `from`), `effect`, `incoming`, `dealt`, `remaining` (the last pool's current) and `lethal`; restorative applications stay silent.
74
+ - `installCaptureCamera`/`readCaptureCamera`/`clearCaptureCamera` and `CaptureCameraOverride` (`@jgengine/core/world/captureCamera`) are the harness counterpart of `?look=`, and the same install-style seam as `world/spawnOverride`: the runner installs a rig, the shell reads it. `resolveCaptureCameraConfig` (`@jgengine/shell/camera/rigResolve`) reads it above a game's static `camera` block, its runtime `ctx.camera.setRig` selection and its director cinematics, so a game that drives its own camera can no longer photograph that camera instead of a requested `--look` framing. With nothing installed the resolution is exactly `resolveRuntimeCameraConfig`, so every game renders as before.
75
+ - `EFFECT_LIBRARY` and `resolveEffect` (`@jgengine/core/vfx/effectLibrary`) name 24 GPU particle effects — fire, smoke, sparks, dust, splashes, muzzle flash, explosions, magic and weather — each authored as rate, lifetime, size, colour and a procedural sprite texture, so `ctx.particles.play("explosion/core", { position })` renders one by name and the editor authors the same name.
76
+ - `ctx.particles.play(name)` and `ctx.particles.attachEffect(id, name)` (`@jgengine/core/vfx/particleDirector`) play a library effect by name; `effectNames()` lists them.
77
+ - `planParticleBudget` (`@jgengine/core/vfx/particleBudget`) caps the live particle count to a graphics tier and drops whole effects lowest-priority-first when the request does not fit.
78
+ - `GraphicsProfile.particleBudget` is the tier's scene-wide live particle cap (low 1024, medium 2048, high 4096, ultra 8192); `particleCap` stays the per-effect pool clamp.
79
+ - `AuthoredEmitter.effect` (`@jgengine/core/editor/simulation`) authors a named library effect in `editor.scene.json` instead of a full config; `config` is now optional and exactly one of the two is required. `SimulationInspector` gains an effect picker.
80
+ - `foliage/grass-macro` and `ground/dirt-macro` in the shared material library: the same ambientCG scans at a 24 m / 20 m tile, so they carry structure at the scale an RTS camera 50 m out resolves instead of texel noise.
81
+ - `libraryTerrainMaterial(name)` (`@jgengine/assets/catalogs/materialLibrary`) returns the terrain detail material config for a library name, tiling from the entry's authored metres and blending from its terrain policy; a map the archive does not ship is omitted.
82
+ - `libraryBuildingSurface(name, options)` and `librarySurfaceParameters(name)` bridge a library name to `BuildingSurfaceMaterial` and `MaterialSurfaceParameters`, so a facade names its material instead of hand-wiring maps, colour and repeat.
83
+ - The library reports a `displacement` role (`libraryMaterialRoles`) without loading it, and `MaterialMaps.displacement` / `TerrainMaterialMaps.displacement` are optional; ambientCG archives without one (Fingerprints001) no longer publish a phantom displacement URL. `libraryTerrainMaterial` passes it through for callers that do displace.
84
+ - `terrain-macro` dev demo captures one field from 52 m in `?mode=flat|dense|macro` to compare ground materials at RTS scale.
85
+ - `SKELETON_STANDARD`, `mapRigToStandard`, `retargetTracks`, `retargetBoneScales` and the `RIG_SCHEMES` table (`@jgengine/core/anim/skeletonStandard`) are the engine's one humanoid skeleton standard: the CC0 KayKit character rig that `kaykit-adventurers` and `kaykit-skeletons` already share, named slot by slot (`root`, `hips`, `spine`, `chest`, `head`, `upperarm.l`…`toes.r`, `handslot.l/r`). A rig on another naming scheme maps onto those slots through a scheme table — Mixamo (`mixamorig*`) and the Quaternius sci-fi aliens ship as `MIXAMO_SCHEME` and `QUATERNIUS_ALIEN_SCHEME` — and every slot the rig cannot fill comes back in `missing` instead of borrowing a neighbour's bone. `retargetTracks` renames a clip's tracks onto a mapped rig, `retargetBoneScales` gives the per-slot length ratios that match bone proportions, and `runtimeBoneName` is the `.`-stripped spelling the loaded hierarchy and clips both use.
86
+ - `locomotionGraph` (`@jgengine/core/anim/locomotionGraph`) now builds the whole directional locomotion set: `strafeLeft`/`strafeRight` add a `strafe` blend on the new `direction` parameter, `backward` adds a `backward` blend behind the new `facing` parameter, and every graph gets a `turn` blend on the new `turnRate` parameter (pivoting on `walk` when no turn clip is authored). Each state joins the idle/walk/run blend through a crossfaded transition, so a direction change blends instead of popping; supplying none of them leaves the speed-only tree unchanged. `LOCOMOTION_DIRECTION_PARAM`, `LOCOMOTION_FACING_PARAM`, `LOCOMOTION_TURN_RATE_PARAM`, `LOCOMOTION_STRAFE_STATE`, `LOCOMOTION_BACKWARD_STATE` and `LOCOMOTION_TURN_STATE` are exported, and a custom mover feeds the three parameters through the entity blackboard key `ANIM_PARAMS_KEY`.
87
+ - `ModelAnimationStates` gains `strafeLeft`, `strafeRight`, `backward` and `turn`.
88
+ - `classifyClip`/`rolesFromClips` know the `strafe` and `backward` clip roles, so `defaultAnimationForClips` derives the KayKit directional states automatically (`Running_Strafe_Left`, `Running_Strafe_Right`, `Walking_Backwards`) for any rig resolved with `animation: "auto"`.
89
+ - `ragdollConfigFromPoses`, `ragdollBoneOrientations`, `RagdollPose`, `RagdollBodyShape` and `HUMANOID_RAGDOLL_BODIES` (`@jgengine/core/anim/ragdollRig`) turn a rig mapped onto the skeleton standard into a physics ragdoll at the moment of death: one box body per standard slot that has a shape, sized to the character, and one distance link per body to its nearest shaped ancestor, holding the bones' own separation. The bodies start where the character was, so the collapse continues the death pose. `handSlot.l/r` carry no body (prop sockets, not body parts); a slot the rig lacks is skipped and its children link to the nearest present ancestor. Because the engine's `PhysicsWorld` bodies carry translation only, `ragdollBoneOrientations` gives the skeleton its orientation by rotating each bone's death-pose direction-to-child onto the current one.
90
+ - `useRagdoll` (`@jgengine/shell/render/useRagdoll`) arms a ragdoll on a rigged character when `active` goes true and drives every mapped bone's world position and aim from its body each frame. It steps the injected `PhysicsWorld`; deactivating removes the bodies and leaves the rig where the ragdoll put it. Stop the character's `ModelConfig.ik` while it is active, or the two write the same joints.
91
+ - `multiplyQuaternions` and `quaternionFromUnitVectors` (`@jgengine/core/anim/ikSolver`) are the two pure rotation helpers the ragdoll's aim needs.
92
+ - `locomotionGraph({ rootMotion: true })` (and `ModelAnimationStates.rootMotion`) marks the whole locomotion set — idle/walk/run, strafe, backward and turn — as root-motion states, so one authored locomotion graph carries its own travel as `AnimGraphOutput.rootDelta` for the movement owner to consume.
93
+ - `actionLayer` and `withActionLayers` (`@jgengine/core/anim/actionLayer`) build a masked overlay layer from plain data: a resting clip plus a one-shot state per entry in `actions`, each joined by a consumed-trigger transition and returning on `exitTime`. Aim, reload, wave and hit reactions are one builder instead of a hand-written state machine per game. `additive: true` makes it a recoil/breathing overlay — the shell's `makeClipAdditive` makes the rest clip's first frame the zero reference, so the rest state is held at `speed: 0` and contributes no pose while the base keeps animating. `mask` is required and nonempty, because an overlay that names no bones would drive the whole rig; `withActionLayers` rejects a duplicate layer id instead of shadowing the base.
94
+ - `standardBoneMask`, `UPPER_BODY_SLOTS`/`LOWER_BODY_SLOTS` and `UPPER_BODY_MASK`/`LOWER_BODY_MASK` (`@jgengine/core/anim/skeletonStandard`) derive a layer mask from the skeleton standard's own slots, so a rig mapped onto the standard is masked without hand-typing its bone names.
95
+ - `ModelLookAtConfig` (`@jgengine/core/game/playableGame`, `ModelConfig.ik.lookAt`) can aim at more than the camera: `target` takes `"camera"` (default), `{ entity }` offset by `entityEyeHeight` (default 1.5), or `{ point }`. `maxYaw`/`maxPitch` bound the turn in radians and `weight` (default 1) scales how much of the solved turn reaches the bone. An entity target that is not spawned keeps its last known direction instead of snapping the head back to the camera. `applyFootIk`'s sixth argument is now `lookAtTarget` and applies the limits and weight; `FootIkRig` carries `lookAtLimits` and `lookAtWeight`.
96
+ - The shell derives the directional locomotion parameters from the entity's own movement, so a rig with the `strafe`/`backward` states uses them with no game-side code: `direction` (the motion's share along the character's right axis), `facing` (its share along the facing axis) and `turnRate` (its yaw change per second) come from the entity's `position` and `rotationY` each visual sample and override conflicting authored extras. `readModelAnimationParams` gains an optional `ModelMotionStep` argument (`dx`, `dz`, `dyaw`, `seconds`); omitting it leaves direction to the authored extras, which is the previous behaviour. A step past the live `snapDistance` reports no direction and zero speed rather than inventing a lateral.
97
+ - `createMenuTransition` (`@jgengine/core/ui/menuTransition`): closed → enter → open → exit → closed timing over an injected clock, with reduced-motion collapse to zero and `snapshot`/`restore`.
98
+ - `@jgengine/core/ui/menuNav`: `menuNavMove` focus movement over a vertical list, horizontal row or grid with disabled-item skipping, `menuIntentForKey`, `menuIntentForPadButton`, `menuIntentForPadAxis` and `shouldRepeatMenuNav` for held d-pad/stick repeat.
99
+ - `MenuScreen` (`@jgengine/react/menuScreen`): animated enter/exit screen with a translucent scrim, a `backdrop` slot for a live 3D scene, reduced-motion collapse and exit unmount. Animates transform/opacity only.
100
+ - `TitleMenu` (`@jgengine/react/titleMenu`): title-screen block — wordmark, eyebrow, tagline, navigable verb list, footer, control hint and backdrop slot. Games own the wording, verbs and art.
101
+ - `useMenuNav` (`@jgengine/react/menuNav`): keyboard and standard-gamepad focus navigation for any menu — roving `tabIndex`, arrow/WASD/Home/End moves, Enter/Space/A confirm, Escape/Backspace/B/Start back, held-stick repeat, disabled-item skipping, no focus trap (Tab stays native, typing is never intercepted) and keys that work when focus starts outside the menu.
102
+ - `PauseMenu` now navigates with the same keyboard/gamepad rules; Resume is the first item and Escape resumes.
103
+ - Engine demo `menu-framework` (`bun run shoot menu-framework --mode play`) renders a live 3D scene behind the title and pause screens.
104
+ - `credits-panel` — `CreditsPanel` (`@jgengine/react/creditsPanel`): a scrollable, HudTheme-skinned credits screen over the scene with one back affordance reachable by keyboard, gamepad and pointer.
105
+ - Loading screen with tips — `LoadingScreen` (`@jgengine/react/loadingScreen`): progress bar (real 0–1 value or indeterminate), rotating tips, a ready gate any key / A / click accepts, reduced-motion aware, and a scene backdrop.
106
+ - `createTipRotation` (`@jgengine/core/ui/loadingTips`): deterministic, seeded tip order that cycles every tip once, with `snapshot`/`restore`.
107
+ - Save slots wired to the save document — `refreshSaveSlots`, `saveSlotMeta`, `formatSavePlaytime` and `gameSaveSlotSource` (`@jgengine/core/game/saveSlotIndex`) read the slots a `createGameSave` actually wrote and fill a `createSaveSlots` index with the name, saved time and playtime chip a save-select menu shows.
108
+ - `SaveStore.peek(slot)` and `GameSave.peek(slot)`: read a slot's stored document without touching the live session, applying sections or changing status.
109
+ - `SaveSlotWrite.savedAt`: a save payload that knows when it was written can stamp that time instead of the index's clock.
110
+ - `@jgengine/core/settings/accessibilitySettings`: the persisted accessibility ids (`accessibility.textScale`, `accessibility.colorblind`, `accessibility.reducedMotion`, `accessibility.highContrast`, `accessibility.captions`, `controls.holdVsToggle`), `accessibilitySettingRows(store)` for a settings surface, `readAccessibilitySettings(store)`, and `holdToggleActive(previous, { pressed, held }, mode)` for hold-or-toggle actions.
111
+ - The shell's settings menu has an `accessibility` category (text size, colourblind palette, reduce motion, high contrast, captions, hold-or-toggle) unless a game hides it. Text size scales every `--jg-type-*` token through `--jg-text-scale`; reduce motion zeroes the engine's motion scale; high contrast, colourblind and captions are exposed as `data-*` hooks on the HUD root.
112
+ - `SettingsSection`, `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect`, `SettingsKeybindControl` and `SettingsTabs` (`@jgengine/react/settingsBlocks`): the settings rows and click-to-capture controls as composable, HudTheme-tokenized blocks, so a game lays out its own settings screen instead of taking the stock dialog. Rebinding writes into the game's existing rebind/holdings store.
113
+ - `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` (`@jgengine/react/accessibilitySettings`) read the live preferences for any custom layout.
114
+ - `HudThemeRoot` takes `accessibility` and applies text scale and the preference data attributes at the HUD root; the shell passes the player's stored preferences there for both the 3D and HUD-only presentation paths.
115
+ - `MissionTickInput.events` (`@jgengine/core/gameplay`) carries the world events that happened this tick - `kill`, `collect`, `deliver` with a target, the actor, free-form `tags` and a count. An objective's `match` filter (`target`, `by`, `tags`, `withoutTags`) credits a constrained counter only from matching events, so a decoy, a wrong weapon or another killer's kill does not advance it.
116
+ - `failOn` accepts `event` (a matching reported event ends the run) and `left-area` (the watched actor is farther than `radius` from an authored marker), completing the reachable fail states: cargo lost, time out, escort died, left the area. `validateMissionCatalog` rejects a `left-area` fail with no actor or unknown marker and a `match` on a non-counter objective instead of ignoring it.
117
+ - `MissionView` now carries `counted`, `count`, `step`, `steps`, `outcome` and `reason`, so a HUD renders "step 2 of 3", a timer and the fail state from `view()` alone. A counter objective's progress mirrors the caller's counters into `state.progress`, so `view()` and a save both see it.
118
+ - `TerrainDetailMaterialConfig.missing` (`@jgengine/core/world/features`): a terrain detail material built from pulled scans no longer takes the whole view down when those maps are not on disk. `"procedural"` (the default) keeps the authored procedural ground and logs one warning naming the missing map, so a game whose asset packs are pulled rather than committed boots anywhere; `"fail"` keeps the presentation retry screen for a view that genuinely cannot render without the scan, which is what the recovery demo asks for.
119
+
120
+ ### Changed
121
+
122
+ - `bun run land` (`scripts/land.ts`) lands a task branch in one command: it refuses `main` or a dirty tree, pushes, opens or reuses the PR into `beta`, runs `--verify`, watches checks, merges and prints `landed <url>`. Exit codes: 0 landed, 1 verify failed, 2 checks failed, 3 could not merge.
123
+ - The CI `test` job is split by path into four matrix shards (`scripts/test-shard.ts`), about 90 s each instead of 5m37s in one job; `ci` still aggregates every shard and no test file is dropped.
124
+ - A loaded model whose meshes share a material now merges to one mesh per material at load, baking each source mesh's transform into the merged vertices. Every instance cloned from that model draws once per material instead of once per sub-mesh, and the instanced scatter/static-batch path harvests the same merged meshes. Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left untouched. A material-assignment `selector.mesh` still resolves a sub-mesh name the merge absorbed.
125
+ - Static authored/`scenery` props batch per model per 128 m cell with a minimum of 2, instead of per model per 64 m cell with a minimum of 3. Props spread thinner than the old minimum in their cell used to fall back to a full individual model draw — Harbor Heat drew 425 of its 488 props that way — and now join one instanced draw per material. Cell size stays configurable because one `InstancedMesh` has one bounding sphere, which three also frustum-tests in the shadow pass: a batch spanning the whole city submits every camera-visible instance to every shadow cascade (Harbor Heat +14% triangles), while 128 m keeps that rejection working (+1.1%).
126
+ - Culling stays per instance: the visibility predicate compacts each batch's drawn range every frame and the batch's bounding sphere is rebuilt from what it draws, so a batch with nothing in view contributes a draw of zero.
127
+ - Static subtrees stop paying three's per-frame matrix pass. `freezeStaticTransforms` (`@jgengine/shell/render/staticTransforms`) bakes a subtree's world matrices once and clears `matrixAutoUpdate`/`matrixWorldAutoUpdate`; `thawStaticTransforms` puts it back for a seam that moves on an event. Batched static props and object/entity marker groups use it.
128
+ - Actor and object marker groups now own their matrix: the per-frame pose write recomposes only when the pose actually changed, so a parked prop or an idle actor leaves its subtree out of that frame's world-matrix pass. Moving actors are unchanged.
129
+ - A postfx prepass no longer re-walks the scene graph. `renderWithCurrentMatrices` (`@jgengine/shell/postfx/shadowPrepass`) draws the stylize normal prepass and the GTAO/DOF prepasses with the matrices the beauty pass built moments earlier in the same frame.
130
+
131
+ Harbor Heat: `updateMatrixWorld` 2.99 ms → 1.66 ms per frame.
132
+ - A rigged model's skinned parts merge into one skinned mesh per skeleton and material at load (`mergeSkinnedModelParts`, `@jgengine/shell/render/modelMeshMerge`). Vertices move into bind space and skin indices are remapped onto the union of the parts' bones, so the posed result is unchanged. A KayKit character's six body parts become one draw and one bone-matrix upload per character.
133
+ - Characters now carry distance tiers. Under 15 m the rig poses every frame, under 40 m every other frame with the accumulated time, and past 25 m a crowd-eligible rig is drawn by the GPU crowd path and stops updating bones. The thresholds live in `ModelConfig.lod` per model; `lod: false` keeps a model at full rate (a player avatar or a cutscene character), and the local player is always full rate. Any animated model with no `ik`, `attachments` or `parts` is crowd-eligible, so a game with no configuration still loses its distant characters' skeletons.
134
+ - `CharacterCrowd` (`@jgengine/shell/render/CharacterCrowd`) draws those far characters: one baked rig per kind, one instanced draw per material, each member's clip, gait phase and speed sampled on the GPU. Entity markers decide the tier, so a character only leaves its rig once the batch for its kind is baked.
135
+
136
+ Harbor Heat at the spawn view: 426 skinned meshes  71, bone pose writes per frame 2,894  41, draws 2,537  1,387, `updateMatrixWorld` 1.47  1.36 ms, frame 25.1  16.7 ms.
137
+ - Shadow casters under a 0.5 m world radius clear `castShadow` as they stream in (`cullSmallShadowCasters`, `@jgengine/shell/render/shadowCasters`). A prop that small is a couple of shadow-map texels wide, so the depth pass spent a draw on a smudge; the authored flag comes back when the mesh leaves the scene. Only clears, so a mesh the game keeps out of the pass stays out. The `render/shadows` devtools system probe reports casters and how many were skipped.
138
+ - Cascaded shadow maps refresh the far cascades on a cadence instead of every frame (`scheduleCascadeRefresh`): with three cascades the nearest redraws every frame, the middle every 2nd and the far every 4th; with four, the two nearest every frame and the rest every 2nd/4th. A skipped cascade keeps the matrix and map from its last refresh, so the pair stays consistent and the shadows do not slide.
139
+ - Cascaded shadow reach now comes from the graphics tier when a game does not author `shadowMaxFar`: the tier's shadow-map resolution sets the reach (512 → 100 m, 1024 → 150 m, 2048/4096 → 200 m), clamped by that tier's draw distance. An authored `shadowMaxFar` still wins, so existing games keep the distance they chose.
140
+
141
+ Harbor Heat's shadow pass self time at `--quality full`: 2.88 → 1.37 ms per frame, `WebGLShadowMap.render` 11.0% → 5.3% of the sampled window; the material patch hook every draw pays dropped out of the top self-time entries (0.83 ms → under 0.3 ms per frame). Frame time is unchanged within the capture noise floor at that view, where the post-processing chain dominates.
142
+ - `EntityDiedEvent.source` and `DeathReason` name what killed the entity: a `player_kill`'s `source` is the killer's instance id, and an environment death carries `attacker` for a non-player entity. `deathReasonFromEffect` fills both from the effect's `from`. Reading an existing environment reason keeps working; a rule that matched `kind` still matches.
143
+ - `ctx.scene.entity.effect` (including telegraph-bound and projectile effects) now raises exactly one `combat.damage` per target on `ctx.game.events`, with the same shape `createDamageRuntime` emits: `type` is the effect id, `remaining`/`lethal` come from the last pool in the catalog's `receive` order. `createDamageRuntime` keeps its own emission, so a game using both paths gets one event per blow, not two.
144
+ - `.claude/skills/jgengine-combat` says which damage path to use: the runtime for typed/zoned/armored damage, the effect path for catalog `receive` pools.
145
+ - The dev runner installs the resolved `?look=`/`--cam=` rig as the capture camera, and the shell's camera rig and presentation both resolve through `resolveCaptureCameraConfig`.
146
+ - `metricsPathFor` (`scripts/shot-metrics`) is the `--inspect` metrics path for a shot. `--out` without an image extension used to swap nothing, so the metrics JSON was written straight over the screenshot; the metrics path now always differs from the shot path.
147
+ - `WorldParticles` allocates the tier's `particleBudget` across the standing emitters and queued bursts with `planParticleBudget`, and reports `cap`/`budgetParticles`/`droppedIds` on its `particles` diagnostics probe.
148
+
149
+ ### Fixed
150
+
151
+ - `settingsBoundary.test.ts` builds its fixture under `.scratch/` inside the checkout instead of `%TEMP%`, so `@jgengine/*` resolves through the workspace and the browser test passes in a fresh worktree with no `dist`; CI was hiding this because it builds first.
152
+ - The skill installer treats an unreadable `.jgengine-version.json` as missing and says which file it ignored, so a half-written or hand-edited marker is rewritten on the next install instead of blocking it; refusal messages now name the metadata file.
153
+ - Skill and doctor fixtures write a realistic next-minor version instead of `99.0.0`, so the CI log no longer reports impossible install versions.
154
+ - `SettingsMenu` keeps Escape closing the dialog while a keybind capture is ending. The capture already consumes Escape at the window, so the extra "wait for capture state" guard only dropped a close request that arrived before the next render, which failed `touchHudReachability` on every `beta` run since #2100.
155
+ - `RoutineTimeline.nextChangeAfter` (`@jgengine/core/sim/routine`) no longer answers with the time it was given when a stop time is fractional. `time` arrives as a whole number of cycles plus a stop time, so its low bits are already rounded; a boundary that rounding put a hair behind `time` read as a change still to come, and the answer repeated, so anything that stepped by it looped forever. Boundaries are compared in absolute time within a few ulps of `time`, so every answer is strictly later. `phaseAt` uses the same tolerance, so a phase that starts exactly on a rounded boundary reports the new phase instead of the walk that led to it.
156
+ - `createOffScreenWorld.refreshCheap` stops catching up instead of spinning if a schedule ever again fails to name a later change: it decays needs to `now`, holds the phase and retries one cycle later.
157
+ - A type token now honors the player's text scale: `--jg-type-*` are `calc(max(floor, size) * var(--jg-text-scale, 1))`, so raising text size resizes every engine block rather than only a menu's own labels.
158
+
29
159
  ## 0.27.0
30
160
 
31
161
  ### Migrate
@@ -1,4 +1,6 @@
1
1
  import { type MaterialAsset, type MaterialSurfaceParameters, type MaterialTextureMetadata, type MaterialTextureRole } from "@jgengine/core/material/materialAsset";
2
+ import type { BuildingSurfaceMaterial } from "@jgengine/core/world/buildings";
3
+ import type { TerrainDetailMaterialConfig } from "@jgengine/core/world/features";
2
4
  /** How a library surface renders: an ordinary PBR surface, cut-out foliage, glass or water. */
3
5
  export type LibrarySurface = "opaque" | "foliage" | "glass" | "water";
4
6
  /** Browse groups the library browser and `assets list` share. */
@@ -21,6 +23,19 @@ export interface LibraryMaterial {
21
23
  tileMetres: number;
22
24
  /** Sparse physical response merged over the imported or authored slot. */
23
25
  response: MaterialSurfaceParameters;
26
+ /**
27
+ * How this scan blends onto terrain when the defaults are wrong for its scale. A macro ground
28
+ * scan tiles over tens of metres, so its own albedo is a broad wash rather than the authored
29
+ * ground colour; it wants to arrive as grain (`tint` low) instead of replacing the biome tint.
30
+ */
31
+ terrain?: LibraryTerrainDetail;
32
+ }
33
+ /** Blend policy for putting a library scan on terrain; only entries whose scale needs it declare one. */
34
+ export interface LibraryTerrainDetail {
35
+ /** Blend strength over the procedural/vertex-colour ground, 0..1. Default 1. */
36
+ strength?: number;
37
+ /** How much of the scan's own colour replaces the authored ground colour, 0..1. Default 1. */
38
+ tint?: number;
24
39
  }
25
40
  /**
26
41
  * The shared library: 40+ ambientCG PBR materials plus the glass and water surfaces that need a
@@ -28,8 +43,8 @@ export interface LibraryMaterial {
28
43
  * response is a type error instead of a silently grey surface.
29
44
  */
30
45
  export declare const MATERIAL_LIBRARY: readonly LibraryMaterial[];
31
- /** Map roles a library scan can carry: the PBR set plus cut-out coverage. */
32
- export type LibraryMapRole = "color" | "normal" | "roughness" | "ao" | "opacity";
46
+ /** Map roles a library scan can carry: the PBR set plus cut-out coverage and a height map. */
47
+ export type LibraryMapRole = "color" | "normal" | "roughness" | "ao" | "opacity" | "displacement";
33
48
  /** Resolve a library name; unknown names return `null` so a caller can report them by name. */
34
49
  export declare function findLibraryMaterial(name: string): LibraryMaterial | null;
35
50
  /** Every library name, in authored order. */
@@ -46,6 +61,9 @@ export interface LibraryMaterialOptions {
46
61
  /**
47
62
  * Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes
48
63
  * into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented.
64
+ * `displacement` is not loaded here: no standard-path renderer samples a height map, and a decoded
65
+ * 1K map per material would cost real GPU memory. Terrain callers get the URL through
66
+ * {@link libraryTerrainMaterial} instead.
49
67
  */
50
68
  export declare function libraryMaterialMaps(entry: LibraryMaterial, options?: LibraryMaterialOptions): Partial<Record<MaterialTextureRole, MaterialTextureMetadata>>;
51
69
  /**
@@ -59,3 +77,46 @@ export declare function libraryMaterialAsset(name: string, options?: LibraryMate
59
77
  * Empty query returns everything, so a browser can render the full library first.
60
78
  */
61
79
  export declare function filterLibraryMaterials(query: string, group?: LibraryGroup): readonly LibraryMaterial[];
80
+ /** Options for {@link libraryTerrainMaterial}. */
81
+ export interface LibraryTerrainOptions {
82
+ /** URL prefix `assets pull` writes under; defaults to `/materials`. */
83
+ basePath?: string;
84
+ /** World units per texture tile; defaults to the entry's authored `tileMetres`. */
85
+ repeat?: number;
86
+ /** Blend strength over the procedural ground, 0..1; the entry's terrain policy, else 1. */
87
+ strength?: number;
88
+ /** How much of the map's own colour replaces the authored ground colour, 0..1; the entry's terrain policy, else 1. */
89
+ tint?: number;
90
+ }
91
+ /**
92
+ * A library name as terrain detail material config: `terrain({ detail: { material: libraryTerrainMaterial("foliage/grass-macro") } })`.
93
+ * Tiling comes from the entry's authored metres and blend from its terrain policy, so a game selects
94
+ * a ground by name instead of assembling map URLs. A map the archive does not ship is omitted, so
95
+ * `displacement` is never a URL the caller had to invent.
96
+ * @capability library-terrain-material put a named library material on terrain without faking optional maps
97
+ */
98
+ export declare function libraryTerrainMaterial(name: string, options?: LibraryTerrainOptions): TerrainDetailMaterialConfig;
99
+ /** Options for {@link libraryBuildingSurface}. */
100
+ export interface LibraryBuildingSurfaceOptions {
101
+ /** URL prefix `assets pull` writes under; defaults to `/materials`. */
102
+ basePath?: string;
103
+ /** Metres one building part slot covers, `[width, height]`; tiles per slot follow from the entry's `tileMetres`. */
104
+ slotMetres?: readonly [number, number];
105
+ /** Tiles per slot, along [x, y]; wins over `slotMetres`. */
106
+ repeat?: number | readonly [number, number];
107
+ /** Albedo multiplier over the map; defaults to the entry's authored response colour. */
108
+ color?: string;
109
+ }
110
+ /**
111
+ * A library name as a building surface: `building({ palette: { wall: libraryBuildingSurface("brick/red", { slotMetres: [12, 3] }) } })`.
112
+ * The maps tile at the entry's authored metres, and the response's colour/roughness/metalness come
113
+ * with them, so a facade picks a material by name instead of hand-wiring a surface per part kind.
114
+ * @capability library-building-surface paint a building part from a named library material
115
+ */
116
+ export declare function libraryBuildingSurface(name: string, options?: LibraryBuildingSurfaceOptions): BuildingSurfaceMaterial;
117
+ /**
118
+ * A library name as the sparse surface parameters a `MaterialAsset` or a render-time override takes,
119
+ * so a material picked by name keeps its authored roughness, metalness and cut-out policy.
120
+ * @capability library-surface-parameters carry a named library material's response into authored overrides
121
+ */
122
+ export declare function librarySurfaceParameters(name: string): MaterialSurfaceParameters;
@@ -51,11 +51,13 @@ export const MATERIAL_LIBRARY = [
51
51
  { name: "foliage/leaf", title: "Leaf card", group: "foliage", source: "ambientcg-leaf001", surface: "foliage", tileMetres: 0.8, response: { metalness: 0, roughness: 0.55, alphaMode: "mask", alphaCutoff: 0.45, doubleSided: true, sheen: 0.6, sheenColor: "#c8d18a", sheenRoughness: 0.7 } },
52
52
  { name: "foliage/leaf-dense", title: "Dense leaf card", group: "foliage", source: "ambientcg-leaf003", surface: "foliage", tileMetres: 1, response: { metalness: 0, roughness: 0.6, alphaMode: "mask", alphaCutoff: 0.45, doubleSided: true, sheen: 0.55, sheenColor: "#b9cc7d", sheenRoughness: 0.75 } },
53
53
  { name: "foliage/grass", title: "Grass ground", group: "foliage", source: "ambientcg-grass001", surface: "opaque", tileMetres: 2, response: { metalness: 0, roughness: 0.9 } },
54
+ { name: "foliage/grass-macro", title: "Grass ground (macro)", group: "foliage", source: "ambientcg-grass001", surface: "opaque", tileMetres: 24, response: { metalness: 0, roughness: 0.9 }, terrain: { strength: 0.6, tint: 0.3 } },
54
55
  { name: "foliage/grass-dry", title: "Dry grass", group: "foliage", source: "ambientcg-grass004", surface: "opaque", tileMetres: 2, response: { metalness: 0, roughness: 0.92 } },
55
56
  { name: "foliage/moss", title: "Moss", group: "foliage", source: "ambientcg-moss001", surface: "opaque", tileMetres: 1.2, response: { metalness: 0, roughness: 0.95 } },
56
57
  { name: "foliage/leaf-litter", title: "Leaf litter", group: "foliage", source: "ambientcg-scatteredleaves008", surface: "opaque", tileMetres: 2.4, response: { metalness: 0, roughness: 0.88 } },
57
58
  // Ground and nature.
58
59
  { name: "ground/dirt", title: "Forest floor", group: "ground", source: "ambientcg-ground037", surface: "opaque", tileMetres: 2.4, response: { metalness: 0, roughness: 0.9 } },
60
+ { name: "ground/dirt-macro", title: "Forest floor (macro)", group: "ground", source: "ambientcg-ground037", surface: "opaque", tileMetres: 20, response: { metalness: 0, roughness: 0.9 }, terrain: { strength: 0.6, tint: 0.3 } },
59
61
  { name: "ground/sand", title: "Beach sand", group: "ground", source: "ambientcg-ground055s", surface: "opaque", tileMetres: 2, response: { metalness: 0, roughness: 0.88 } },
60
62
  { name: "ground/sand-dark", title: "Dark sand", group: "ground", source: "ambientcg-ground080", surface: "opaque", tileMetres: 2, response: { metalness: 0, roughness: 0.86 } },
61
63
  { name: "ground/mud", title: "Wet mud", group: "ground", source: "ambientcg-ground095b", surface: "opaque", tileMetres: 2, response: { metalness: 0, roughness: 0.52 } },
@@ -81,7 +83,11 @@ export const MATERIAL_LIBRARY = [
81
83
  ];
82
84
  const byName = new Map(MATERIAL_LIBRARY.map((entry) => [entry.name, entry]));
83
85
  /** Every readable map role a library material can carry, in load order. */
84
- const LIBRARY_ROLES = ["color", "normal", "roughness", "ao", "opacity"];
86
+ const LIBRARY_ROLES = ["color", "normal", "roughness", "ao", "opacity", "displacement"];
87
+ /** Authoring role a library map feeds: `opacity` is an alpha map, `displacement` is a height map. */
88
+ function textureRoleFor(role) {
89
+ return role === "opacity" ? "alpha" : role === "displacement" ? "height" : role;
90
+ }
85
91
  /** Resolve a library name; unknown names return `null` so a caller can report them by name. */
86
92
  export function findLibraryMaterial(name) {
87
93
  return byName.get(name) ?? null;
@@ -102,13 +108,18 @@ export function libraryMaterialRoles(entry) {
102
108
  /**
103
109
  * Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes
104
110
  * into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented.
111
+ * `displacement` is not loaded here: no standard-path renderer samples a height map, and a decoded
112
+ * 1K map per material would cost real GPU memory. Terrain callers get the URL through
113
+ * {@link libraryTerrainMaterial} instead.
105
114
  */
106
115
  export function libraryMaterialMaps(entry, options = {}) {
107
116
  const base = `${(options.basePath ?? "/materials").replace(/\/+$/, "")}/${entry.source ?? ""}`;
108
117
  const source = entry.source === undefined ? undefined : sourceById.get(entry.source);
109
118
  const textures = {};
110
119
  for (const role of libraryMaterialRoles(entry)) {
111
- const target = role === "opacity" ? "alpha" : role;
120
+ if (role === "displacement")
121
+ continue;
122
+ const target = textureRoleFor(role);
112
123
  textures[target] = {
113
124
  url: `${base}/${MATERIAL_MAP_FILES[role]}`,
114
125
  colorSpace: MATERIAL_TEXTURE_SEMANTICS[target].colorSpace,
@@ -171,3 +182,90 @@ export function filterLibraryMaterials(query, group) {
171
182
  return `${entry.name} ${entry.title} ${entry.group} ${entry.source ?? ""}`.toLowerCase().includes(needle);
172
183
  });
173
184
  }
185
+ function requireLibraryMaterial(name) {
186
+ const entry = findLibraryMaterial(name);
187
+ if (entry === null)
188
+ throw new TypeError(`Unknown library material "${name}"`);
189
+ return entry;
190
+ }
191
+ /** Pulled URL for one map role, or `undefined` when this entry's archive never ships it. */
192
+ function mapUrl(entry, role, basePath) {
193
+ if (entry.source === undefined || !libraryMaterialRoles(entry).includes(role))
194
+ return undefined;
195
+ return `${basePath.replace(/\/+$/, "")}/${entry.source}/${MATERIAL_MAP_FILES[role]}`;
196
+ }
197
+ /**
198
+ * A library name as terrain detail material config: `terrain({ detail: { material: libraryTerrainMaterial("foliage/grass-macro") } })`.
199
+ * Tiling comes from the entry's authored metres and blend from its terrain policy, so a game selects
200
+ * a ground by name instead of assembling map URLs. A map the archive does not ship is omitted, so
201
+ * `displacement` is never a URL the caller had to invent.
202
+ * @capability library-terrain-material put a named library material on terrain without faking optional maps
203
+ */
204
+ export function libraryTerrainMaterial(name, options = {}) {
205
+ const entry = requireLibraryMaterial(name);
206
+ const basePath = options.basePath ?? "/materials";
207
+ const color = mapUrl(entry, "color", basePath);
208
+ const normal = mapUrl(entry, "normal", basePath);
209
+ if (color === undefined || normal === undefined) {
210
+ throw new TypeError(`Library material "${name}" carries no pulled color/normal maps; terrain needs a scanned surface.`);
211
+ }
212
+ const roughness = mapUrl(entry, "roughness", basePath);
213
+ const ao = mapUrl(entry, "ao", basePath);
214
+ const displacement = mapUrl(entry, "displacement", basePath);
215
+ const maps = {
216
+ color,
217
+ normal,
218
+ ...(roughness === undefined ? {} : { roughness }),
219
+ ...(ao === undefined ? {} : { ao }),
220
+ ...(displacement === undefined ? {} : { displacement }),
221
+ };
222
+ return {
223
+ maps,
224
+ repeat: options.repeat ?? entry.tileMetres,
225
+ strength: options.strength ?? entry.terrain?.strength ?? 1,
226
+ tint: options.tint ?? entry.terrain?.tint ?? 1,
227
+ };
228
+ }
229
+ /**
230
+ * A library name as a building surface: `building({ palette: { wall: libraryBuildingSurface("brick/red", { slotMetres: [12, 3] }) } })`.
231
+ * The maps tile at the entry's authored metres, and the response's colour/roughness/metalness come
232
+ * with them, so a facade picks a material by name instead of hand-wiring a surface per part kind.
233
+ * @capability library-building-surface paint a building part from a named library material
234
+ */
235
+ export function libraryBuildingSurface(name, options = {}) {
236
+ const entry = requireLibraryMaterial(name);
237
+ const basePath = options.basePath ?? "/materials";
238
+ const color = mapUrl(entry, "color", basePath);
239
+ const normal = mapUrl(entry, "normal", basePath);
240
+ if (color === undefined || normal === undefined) {
241
+ throw new TypeError(`Library material "${name}" carries no pulled color/normal maps; a shader surface has nothing to tile onto a building.`);
242
+ }
243
+ const roughness = mapUrl(entry, "roughness", basePath);
244
+ const ao = mapUrl(entry, "ao", basePath);
245
+ const maps = {
246
+ color,
247
+ normal,
248
+ ...(roughness === undefined ? {} : { roughness }),
249
+ ...(ao === undefined ? {} : { ao }),
250
+ };
251
+ const repeat = options.repeat
252
+ ?? (options.slotMetres === undefined
253
+ ? undefined
254
+ : [options.slotMetres[0] / entry.tileMetres, options.slotMetres[1] / entry.tileMetres]);
255
+ const surfaceColor = options.color ?? entry.response.color;
256
+ return {
257
+ maps,
258
+ ...(surfaceColor === undefined ? {} : { color: surfaceColor }),
259
+ ...(repeat === undefined ? {} : { repeat }),
260
+ ...(entry.response.roughness === undefined ? {} : { roughness: entry.response.roughness }),
261
+ ...(entry.response.metalness === undefined ? {} : { metalness: entry.response.metalness }),
262
+ };
263
+ }
264
+ /**
265
+ * A library name as the sparse surface parameters a `MaterialAsset` or a render-time override takes,
266
+ * so a material picked by name keeps its authored roughness, metalness and cut-out policy.
267
+ * @capability library-surface-parameters carry a named library material's response into authored overrides
268
+ */
269
+ export function librarySurfaceParameters(name) {
270
+ return { ...requireLibraryMaterial(name).response };
271
+ }
@@ -14,7 +14,7 @@ export interface ExtractedMaterialMap {
14
14
  * URLs never depend on the provider's naming or the pulled resolution.
15
15
  */
16
16
  export declare function extractMaterialMaps(archive: Uint8Array): ExtractedMaterialMap[];
17
- /** URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2` appear only when declared by its source. */
17
+ /** URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2`/`displacement` appear only when declared by its source. */
18
18
  export interface MaterialMaps {
19
19
  color: string;
20
20
  normal: string;
@@ -22,7 +22,8 @@ export interface MaterialMaps {
22
22
  ao?: string;
23
23
  /** Cut-out coverage map, present on foliage and decal-style scans. */
24
24
  opacity?: string;
25
- displacement: string;
25
+ /** Height map for callers that displace geometry; absent on archives published without one. */
26
+ displacement?: string;
26
27
  ktx2?: string;
27
28
  }
28
29
  /** A resolved material: identity, attribution, and its normalized map URLs. */
package/dist/materials.js CHANGED
@@ -62,7 +62,7 @@ function refFor(source, basePath) {
62
62
  ...(source.materialMaps?.includes("roughness") ? { roughness: `${base}/${MATERIAL_MAP_FILES.roughness}` } : {}),
63
63
  ...(source.materialMaps?.includes("ao") ? { ao: `${base}/${MATERIAL_MAP_FILES.ao}` } : {}),
64
64
  ...(source.materialMaps?.includes("opacity") ? { opacity: `${base}/${MATERIAL_MAP_FILES.opacity}` } : {}),
65
- displacement: `${base}/${MATERIAL_MAP_FILES.displacement}`,
65
+ ...(source.materialMaps?.includes("displacement") ? { displacement: `${base}/${MATERIAL_MAP_FILES.displacement}` } : {}),
66
66
  ...(source.materialMaps?.includes("ktx2") ? { ktx2: `${base}/${MATERIAL_MAP_FILES.ktx2}` } : {}),
67
67
  },
68
68
  };
package/dist/snippet.js CHANGED
@@ -30,7 +30,7 @@ export function materialWiringSnippet(id, basePath = "/materials") {
30
30
  `export const materials = buildMaterialCatalog({ basePath: ${JSON.stringify(basePath)} });`,
31
31
  ``,
32
32
  `const material = materials.resolve(${JSON.stringify(id)})!;`,
33
- `// material.maps => { color, normal, displacement, roughness?, ao?, ktx2? } URLs under ${basePath}/${id}/`,
33
+ `// material.maps => { color, normal, displacement?, roughness?, ao?, ktx2? } URLs under ${basePath}/${id}/`,
34
34
  ``,
35
35
  `// onto the ground (terrain() in defineGame({ world: environment({ terrain: ... }) })):`,
36
36
  `terrain({ detail: { material: { maps: material.maps, repeat: 4 } } })`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/assets",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Self-generating, license-verified index of thousands of CC0 3D models for JGengine. Sources fetch from providers' own CDNs at pull time; the npm tarball ships only the typed index and CLI (no GLB bytes).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "@gltf-transform/core": "4.2.1",
39
39
  "@gltf-transform/extensions": "4.2.1",
40
40
  "@gltf-transform/functions": "4.2.1",
41
- "@jgengine/core": "^0.27.0",
41
+ "@jgengine/core": "^0.28.0",
42
42
  "fflate": "^0.8.2"
43
43
  },
44
44
  "devDependencies": {
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.27.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.27.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -58,9 +58,13 @@ Material sources declare the map roles in their pinned archive through `AssetSou
58
58
 
59
59
  ## Material library
60
60
 
61
- `@jgengine/assets/catalogs/materialLibrary` is the shared palette: `MATERIAL_LIBRARY` names 56 surfaces (`asphalt/wet`, `metal/painted`, `stucco/painted`, `brick/red`, `wood/oak`, `foliage/leaf`, `glass/clear`, `water/ocean`, `ground/sand`, `concrete/smooth`, …), each with its ambientCG source, world tile size in metres and physical response. Select by name — never by source id or filename.
61
+ `@jgengine/assets/catalogs/materialLibrary` is the shared palette: `MATERIAL_LIBRARY` names 58 surfaces (`asphalt/wet`, `metal/painted`, `stucco/painted`, `brick/red`, `wood/oak`, `foliage/leaf`, `glass/clear`, `water/ocean`, `ground/sand`, `concrete/smooth`, …), each with its ambientCG source, world tile size in metres and physical response. Select by name — never by source id or filename.
62
62
 
63
- - `assets pull library --dir <public>` pulls every pinned map set (about 360 MB of 1K-JPG archives; each pulled material is then 5–7 MB beside its name). Pull into a game's `public/materials` and build assets with `libraryMaterialAsset(name)`; the returned `MaterialAsset` is the same editable value the editor writes into `editor.scene.json`.
63
+ - `assets pull library --dir <public>` pulls every pinned map set (about 360 MB of 1K-JPG archives; each pulled material is then 5-7 MB beside its name). Pull into a game's `public/materials` and build assets with `libraryMaterialAsset(name)`; the returned `MaterialAsset` is the same editable value the editor writes into `editor.scene.json`.
64
+ - Three bridges turn a name into the seam each consumer takes, so a game never hand-wires URLs: `libraryTerrainMaterial(name)` is a `TerrainDetailMaterialConfig` (`terrain({ detail: { material: … } })`), `libraryBuildingSurface(name, { slotMetres })` is a `BuildingSurfaceMaterial` (a `building()` palette entry), and `librarySurfaceParameters(name)` is the sparse `MaterialSurfaceParameters` a `MaterialAsset` or an override carries.
65
+ - Tiling comes from the entry's authored metres: `libraryTerrainMaterial` uses `tileMetres` for `repeat`, and `libraryBuildingSurface` derives tiles-per-slot from `slotMetres`. Both throw on a shader surface (glass, water) rather than tiling a scan that does not exist.
66
+ - A **macro** entry is the same scan at a much larger tile (`foliage/grass-macro` 24 m, `ground/dirt-macro` 20 m) for cameras 50 m and further out. At that distance a 2 m tile resolves to sub-pixel noise that averages back to one flat colour; a 24 m tile still shows structure. Its `terrain` policy keeps the authored ground colour (`tint` low) and takes only the scan's grain. `?mode=flat|dense|macro` on the `terrain-macro` dev demo compares the three at 52 m.
67
+ - Library map roles are `color`, `normal`, `roughness`, `ao`, `opacity` and `displacement`; `displacement` is reported by `libraryMaterialRoles` and passed through `libraryTerrainMaterial`, but `libraryMaterialAsset` does not load it, because no standard-path renderer samples a height map. Never construct that URL by hand to satisfy a type.
64
68
  - Clear and frosted glass, ocean and puddle carry no scan: ambientCG publishes no glass or water material, so those four are response parameters. `glass/smudged` adds ambientCG's fingerprint normal.
65
69
  - Geometry without a `uv` attribute renders through `@jgengine/shell/render/triplanarMaterial` (`TriplanarAssetMaterial`, `useTriplanarAssetMaterial`, or `applyTriplanarProjection` on an owned material). It projects colour, normal, roughness and AO from world position at the asset's tile size, and instanced meshes project in instanced space. Use it for generated or imported world geometry; keep UV-mapped models on the ordinary path.
66
70
  - Metals need an environment to reflect. A metal entry under a bare clear colour reads as coloured chrome — pair `metal/*` with `EnvironmentLighting` or an authored sky.
@@ -36,7 +36,7 @@
36
36
  - `MATERIAL_MAP_FILES` (const): const MATERIAL_MAP_FILES: { readonly color: "color.jpg"; readonly normal: "normal.jpg"; readonly roughness: "roughness.jpg"; readonly ao: "ao.jpg"; readonly opacity: "opacity.jpg"; readonly displacement: "displacement.jpg"; readonly ktx2: "material.ktx2"; } — Normalized filenames a pulled material directory contains, keyed by map role. Kept in its own module so catalogs and editors can name a pulled file without loading the archive reader.
37
37
  - `MaterialCatalog` (interface): interface MaterialCatalog — Resolves material ids and `material/…` aliases to `MaterialRef`s.
38
38
  - `MaterialMapRole` (type): type MaterialMapRole = keyof typeof MATERIAL_MAP_FILES — One PBR map's role within a material, including an optional compressed KTX2 asset.
39
- - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2` appear only when declared by its source.
39
+ - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2`/`displacement` appear only when declared by its source.
40
40
  - `MaterialRef` (interface): interface MaterialRef — A resolved material: identity, attribution, and its normalized map URLs.
41
41
  - `ModelDims` (interface): interface ModelDims — Measured horizontal footprint, footprint center, and lowest Y of a model in model space.
42
42
  - `ModelSnippetOptions` (interface): interface ModelSnippetOptions { basePath?: string; seam?: "objectModels" | "entityModels" } — ⚠ undocumented · used by `modelWiringSnippet`: Copy-paste wiring for a pulled GLB id: resolve through the catalog, drop into a model seam.
@@ -84,7 +84,7 @@
84
84
  - `kaykitSources` (const): const kaykitSources: readonly AssetSource[] — ⚠ undocumented
85
85
  - `keyFromSpriteFile` (function): function keyFromSpriteFile(file: string): string — Strips the `.svg`/`.png` extension off a pulled sprite/icon filename to get its id suffix.
86
86
  - `libraryMaterialAsset` (function): function libraryMaterialAsset(name: string, options: LibraryMaterialOptions = {}): MaterialAsset — Build the editable, serializable material asset for a library name: maps, response and CC0 attribution in one value the editor can save into `editor.scene.json` and the shell renders. `projection` records whether those maps should sample authored UVs or world-space triplanar.
87
- - `libraryMaterialMaps` (function): function libraryMaterialMaps(entry: LibraryMaterial, options: LibraryMaterialOptions = {}): Partial<Record<MaterialTextureRole, MaterialTextureMetadata>> — Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented.
87
+ - `libraryMaterialMaps` (function): function libraryMaterialMaps(entry: LibraryMaterial, options: LibraryMaterialOptions = {}): Partial<Record<MaterialTextureRole, MaterialTextureMetadata>> — Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented. `displacement` is not loaded here: no standard-path renderer samples a height map, and a decoded 1K map per material would cost real GPU memory. Terrain callers get the URL through {@link libraryTerrainMaterial} instead.
88
88
  - `libraryMaterialNames` (function): function libraryMaterialNames(): readonly string[] — Every library name, in authored order.
89
89
  - `libraryMaterialRoles` (function): function libraryMaterialRoles(entry: LibraryMaterial): readonly LibraryMapRole[] — Map roles the pinned archive of `entry` actually ships, from its catalog source declaration.
90
90
  - `materialAliases` (const): const materialAliases: readonly AssetAlias[] — Semantic keys onto the ambientCG catalog, mirroring the model alias layer.
@@ -135,18 +135,24 @@
135
135
 
136
136
  ## @jgengine/assets/catalogs/materialLibrary
137
137
 
138
+ - `LibraryBuildingSurfaceOptions` (interface): interface LibraryBuildingSurfaceOptions — Options for {@link libraryBuildingSurface}.
138
139
  - `LibraryGroup` (type): type LibraryGroup = | "asphalt" | "concrete" | "stucco" | "brick" | "tile" | "metal" | "wood" | "foliage" | "ground" | "fabric" | "stone" | "glass" | "water" — Browse groups the library browser and `assets list` share.
139
- - `LibraryMapRole` (type): type LibraryMapRole = "color" | "normal" | "roughness" | "ao" | "opacity" — Map roles a library scan can carry: the PBR set plus cut-out coverage.
140
+ - `LibraryMapRole` (type): type LibraryMapRole = "color" | "normal" | "roughness" | "ao" | "opacity" | "displacement" — Map roles a library scan can carry: the PBR set plus cut-out coverage and a height map.
140
141
  - `LibraryMaterial` (interface): interface LibraryMaterial — One named material a game or the editor selects by name. `source` names the ambientCG catalog entry whose pulled maps it uses; glass and water carry response parameters only, because ambientCG publishes no scanned glass or water material and both are shader surfaces.
141
142
  - `LibraryMaterialOptions` (interface): interface LibraryMaterialOptions — Options for {@link libraryMaterialMaps} and {@link libraryMaterialAsset}.
142
143
  - `LibrarySurface` (type): type LibrarySurface = "opaque" | "foliage" | "glass" | "water" — How a library surface renders: an ordinary PBR surface, cut-out foliage, glass or water.
144
+ - `LibraryTerrainDetail` (interface): interface LibraryTerrainDetail — Blend policy for putting a library scan on terrain; only entries whose scale needs it declare one.
145
+ - `LibraryTerrainOptions` (interface): interface LibraryTerrainOptions — Options for {@link libraryTerrainMaterial}.
143
146
  - `MATERIAL_LIBRARY` (const): const MATERIAL_LIBRARY: readonly LibraryMaterial[] — The shared library: 40+ ambientCG PBR materials plus the glass and water surfaces that need a shader rather than a scan. `satisfies` keeps every entry complete, so a missing tile size or response is a type error instead of a silently grey surface.
144
147
  - `filterLibraryMaterials` (function): function filterLibraryMaterials(query: string, group?: LibraryGroup): readonly LibraryMaterial[] — Filter the library for a picker: case-insensitive match on name, title, group or source. Empty query returns everything, so a browser can render the full library first.
145
148
  - `findLibraryMaterial` (function): function findLibraryMaterial(name: string): LibraryMaterial | null — Resolve a library name; unknown names return `null` so a caller can report them by name.
149
+ - `libraryBuildingSurface` (function): function libraryBuildingSurface(name: string, options: LibraryBuildingSurfaceOptions = {}): BuildingSurfaceMaterial — A library name as a building surface: `building({ palette: { wall: libraryBuildingSurface("brick/red", { slotMetres: [12, 3] }) } })`. The maps tile at the entry's authored metres, and the response's colour/roughness/metalness come with them, so a facade picks a material by name instead of hand-wiring a surface per part kind.
146
150
  - `libraryMaterialAsset` (function): function libraryMaterialAsset(name: string, options: LibraryMaterialOptions = {}): MaterialAsset — Build the editable, serializable material asset for a library name: maps, response and CC0 attribution in one value the editor can save into `editor.scene.json` and the shell renders. `projection` records whether those maps should sample authored UVs or world-space triplanar.
147
- - `libraryMaterialMaps` (function): function libraryMaterialMaps(entry: LibraryMaterial, options: LibraryMaterialOptions = {}): Partial<Record<MaterialTextureRole, MaterialTextureMetadata>> — Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented.
151
+ - `libraryMaterialMaps` (function): function libraryMaterialMaps(entry: LibraryMaterial, options: LibraryMaterialOptions = {}): Partial<Record<MaterialTextureRole, MaterialTextureMetadata>> — Serialized runtime map URLs for a library material, matching what `assets pull <source>` writes into `<basePath>/<source-id>/`. Roles the archive does not ship are omitted, never invented. `displacement` is not loaded here: no standard-path renderer samples a height map, and a decoded 1K map per material would cost real GPU memory. Terrain callers get the URL through {@link libraryTerrainMaterial} instead.
148
152
  - `libraryMaterialNames` (function): function libraryMaterialNames(): readonly string[] — Every library name, in authored order.
149
153
  - `libraryMaterialRoles` (function): function libraryMaterialRoles(entry: LibraryMaterial): readonly LibraryMapRole[] — Map roles the pinned archive of `entry` actually ships, from its catalog source declaration.
154
+ - `librarySurfaceParameters` (function): function librarySurfaceParameters(name: string): MaterialSurfaceParameters — A library name as the sparse surface parameters a `MaterialAsset` or a render-time override takes, so a material picked by name keeps its authored roughness, metalness and cut-out policy.
155
+ - `libraryTerrainMaterial` (function): function libraryTerrainMaterial(name: string, options: LibraryTerrainOptions = {}): TerrainDetailMaterialConfig — A library name as terrain detail material config: `terrain({ detail: { material: libraryTerrainMaterial("foliage/grass-macro") } })`. Tiling comes from the entry's authored metres and blend from its terrain policy, so a game selects a ground by name instead of assembling map URLs. A map the archive does not ship is omitted, so `displacement` is never a URL the caller had to invent.
150
156
 
151
157
  ## @jgengine/assets/catalogs/starter
152
158
 
@@ -274,7 +280,7 @@
274
280
  - `MATERIAL_MAP_FILES` (const): const MATERIAL_MAP_FILES: { readonly color: "color.jpg"; readonly normal: "normal.jpg"; readonly roughness: "roughness.jpg"; readonly ao: "ao.jpg"; readonly opacity: "opacity.jpg"; readonly displacement: "displacement.jpg"; readonly ktx2: "material.ktx2"; } — Normalized filenames a pulled material directory contains, keyed by map role. Kept in its own module so catalogs and editors can name a pulled file without loading the archive reader.
275
281
  - `MaterialCatalog` (interface): interface MaterialCatalog — Resolves material ids and `material/…` aliases to `MaterialRef`s.
276
282
  - `MaterialMapRole` (type): type MaterialMapRole = keyof typeof MATERIAL_MAP_FILES — One PBR map's role within a material, including an optional compressed KTX2 asset.
277
- - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2` appear only when declared by its source.
283
+ - `MaterialMaps` (interface): interface MaterialMaps — URLs of one material's PBR maps; `ao`/`roughness`/`opacity`/`ktx2`/`displacement` appear only when declared by its source.
278
284
  - `MaterialRef` (interface): interface MaterialRef — A resolved material: identity, attribution, and its normalized map URLs.
279
285
  - `buildMaterialCatalog` (function): function buildMaterialCatalog(options: BuildMaterialCatalogOptions = {}): MaterialCatalog — A resolvable catalog over every `kind: "material"` source. Ids are source ids (`ambientcg-grass001`) plus the `material/…` aliases; every resolve returns the normalized map URLs under `basePath`, matching what `assets pull` writes into `<dir>/materials/<id>/`.
280
286
  - `extractMaterialMaps` (function): function extractMaterialMaps(archive: Uint8Array): ExtractedMaterialMap[] — Pulls the recognized PBR maps out of a material archive (ambientCG's flat `<Asset>_<Res>_<Map>.jpg` layout) and normalizes their names so resolved URLs never depend on the provider's naming or the pulled resolution.
@@ -48,6 +48,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
48
48
 
49
49
  - `validateHairCardAuthoring` (function) · `import { validateHairCardAuthoring } from "@jgengine/core/material/hairCards"`
50
50
 
51
+ ## library-building-surface — paint a building part from a named library material
52
+
53
+ - `libraryBuildingSurface` (function) · `import { libraryBuildingSurface } from "@jgengine/assets/catalogs/materialLibrary"`
54
+
55
+ ## library-surface-parameters — carry a named library material's response into authored overrides
56
+
57
+ - `librarySurfaceParameters` (function) · `import { librarySurfaceParameters } from "@jgengine/assets/catalogs/materialLibrary"`
58
+
59
+ ## library-terrain-material — put a named library material on terrain without faking optional maps
60
+
61
+ - `libraryTerrainMaterial` (function) · `import { libraryTerrainMaterial } from "@jgengine/assets/catalogs/materialLibrary"`
62
+
51
63
  ## material-feature-requirements — Declare material shader requirements when authors add maps or appearance adapters.
52
64
 
53
65
  - `materialCapabilitiesForAsset` (function) · `import { materialCapabilitiesForAsset } from "@jgengine/core/material/materialAsset"`