@jgengine/core 0.13.0 → 0.14.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
@@ -10,7 +10,53 @@ let consumers pick up the better stuff, not just to list what moved.
10
10
  Agents building on the published SDK can also read this programmatically:
11
11
  `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
12
12
  same data as typed values, so an updater can diff its installed version against
13
- the latest and surface the migration steps.
13
+ the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
14
+ run inside a game project diffs the installed `@jgengine/*` versions against the
15
+ latest release and prints every Migrate step and Adopt-worthy addition in
16
+ between (`--json` for structured output).
17
+
18
+ ## [Unreleased]
19
+
20
+ <!--
21
+ Every PR that changes `packages/*/src` records its consumer-facing change here, so
22
+ the next release's notes are complete by construction. Add a bullet under the right
23
+ subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
24
+ is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
25
+ At publish, rename this heading to the new version and mirror the entries into
26
+ `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
+ -->
28
+
29
+ ### Migrate
30
+
31
+ - _Nothing yet._
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Removed
38
+
39
+ ## 0.14.0
40
+
41
+ ### Migrate
42
+
43
+ - **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
44
+ - One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
45
+
46
+ ### Added
47
+
48
+ - One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
49
+ - **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
50
+ - Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
51
+ - **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
52
+ - **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
53
+ - **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
54
+
55
+ ### Changed
56
+
57
+ - **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
58
+ - **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
59
+ - **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
14
60
 
15
61
  ## 0.13.0
16
62
 
@@ -1,5 +1,5 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export declare const VERSION = "0.13.0";
2
+ export declare const VERSION = "0.14.0";
3
3
  /** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
4
4
  export interface ChangelogEntry {
5
5
  migrate: readonly string[];
@@ -1,7 +1,27 @@
1
1
  /** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
2
- export const VERSION = "0.13.0";
2
+ export const VERSION = "0.14.0";
3
3
  /** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
4
4
  export const CHANGELOG = {
5
+ "0.14.0": {
6
+ migrate: [
7
+ "Bump lockstep SDK packages to ^0.14.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.11.0; @jgengine/github is unchanged at 0.1.0.",
8
+ "Nothing required — additive drop-in primitives, editor authoring, CLI discovery, and fixes. One behavior fix: in a rig: \"chase\" game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig).",
9
+ ],
10
+ added: [
11
+ "One authoring gesture for scatterable coverage — @jgengine/core/world/scatterCoverage owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for grass_field/scatter/city; the editor inspector leads each kind with the same Area → Assets → Density coverage section.",
12
+ "Created games ship a drive/playtest script (#1248) — npx jgengine create scaffolds include scripts/drive.mjs (bun run drive): dependency-free headless play/testing via ordered --click/--key/--wait/--shot/--rpc steps against the agent bridge, plus --playtest --strict verdicts off capture.probe.",
13
+ "Editor viewport clip preview for rigged assets (Animation dock Clips mode) and an Inspector Animation section authoring a placement's ModelConfig.animation as undoable marker.meta.animation edits. New subpaths: @jgengine/editor/shell/clipPreview, @jgengine/editor/modelAnimationAuthoring, @jgengine/editor/ClipPreviewLayer.",
14
+ "npx jgengine find <intent> — active capability discovery: searches every shipped domain's capabilities.md and prints the drop-in primitive + its import for an intent like \"toggleable window\", inventory, or minimap.",
15
+ "EntityPreview (@jgengine/shell/render/EntityPreview) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers; owns the nested Canvas, GameContextBridge, and a StudioStage lighting rig with optional turntable / face-camera.",
16
+ "GameContextBridge (@jgengine/react) — re-provide the running GameContext across a nested React reconciler boundary (the R3F Canvas).",
17
+ ],
18
+ changed: [
19
+ "Chase rig reports its yaw to the shell — on-foot movement and aim in a rig: \"chase\" game are camera-relative instead of frozen to world yaw 0; interior views (cockpit/hood/rear) report the vehicle heading.",
20
+ "Combat VFX no longer render as black squares under AO/DOF post-processing (#1247) — GTAO/Bokeh scene prepasses skip overlay effects; custom additive overlays opt out by spreading POSTFX_OVERLAY_USERDATA (@jgengine/shell/postfx/postfxOverlay) onto the overlay group's userData.",
21
+ "PanelHost windows stack above the HUD by default (@jgengine/react) — the host establishes its own stacking context at zIndexBase (default 40), overridable per instance.",
22
+ ],
23
+ removed: [],
24
+ },
5
25
  "0.13.0": {
6
26
  migrate: [
7
27
  "Bump lockstep SDK packages to ^0.13.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.10.0; @jgengine/github is unchanged.",
@@ -5,6 +5,7 @@ import { type EntityStatsApi, type StatValueMap } from "../../scene/entityStats.
5
5
  import { type EntityPosition, type EntityStore, type SceneEntity, type SpawnOptions } from "../../scene/entityStore.js";
6
6
  import { type Forms } from "../../scene/form.js";
7
7
  import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
8
+ import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
8
9
  import { type ObjectStore } from "../../scene/objectStore.js";
9
10
  import { type PaintLayer } from "../../scene/paintLayer.js";
10
11
  import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
@@ -42,6 +43,7 @@ export interface SceneSubsystem {
42
43
  entityCollidersOf: (instanceId: string) => EntityColliderSet | null;
43
44
  objectCollidersOf: (instanceId: string) => EntityColliderSet | null;
44
45
  reportEntityBounds: (kind: string, bounds: MeasuredBounds | null) => boolean;
46
+ reportEntityCollisionMesh: (kind: string, mesh: CollisionMeshSource | null) => boolean;
45
47
  entityVisualScaleOf: (instanceId: string) => number;
46
48
  forms: Forms;
47
49
  paintLayer: PaintLayer;
@@ -2,7 +2,8 @@ import { createBodyBind } from "../../scene/bodyBind.js";
2
2
  import { createEntityStatsApi, seedStatValues, } from "../../scene/entityStats.js";
3
3
  import { createEntityStore, } from "../../scene/entityStore.js";
4
4
  import { createForms } from "../../scene/form.js";
5
- import { fittedEntityColliders, fittedObjectColliders, measuredEntityColliders, measuredObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
5
+ import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, measuredEntityColliders, measuredObjectColliders, meshEntityColliders, meshObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
6
+ import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
6
7
  import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
7
8
  import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
8
9
  import { createPaintLayer } from "../../scene/paintLayer.js";
@@ -98,6 +99,38 @@ export function createSceneSubsystem(d) {
98
99
  signalNotify();
99
100
  return true;
100
101
  }
102
+ const meshEntityByKind = new Map();
103
+ const meshObjectByCatalogId = new Map();
104
+ function reportEntityCollisionMesh(kind, mesh) {
105
+ if (mesh === null) {
106
+ const existed = meshEntityByKind.delete(kind);
107
+ if (existed)
108
+ signalNotify();
109
+ return existed;
110
+ }
111
+ const prepared = prepareCollisionMeshSource(mesh);
112
+ const set = prepared === null ? null : meshEntityColliders(prepared);
113
+ if (set === null)
114
+ return false;
115
+ meshEntityByKind.set(kind, set);
116
+ signalNotify();
117
+ return true;
118
+ }
119
+ function reportObjectCollisionMesh(catalogId, mesh) {
120
+ if (mesh === null) {
121
+ const existed = meshObjectByCatalogId.delete(catalogId);
122
+ if (existed)
123
+ signalNotify();
124
+ return existed;
125
+ }
126
+ const prepared = prepareCollisionMeshSource(mesh);
127
+ const set = prepared === null ? null : meshObjectColliders(prepared);
128
+ if (set === null)
129
+ return false;
130
+ meshObjectByCatalogId.set(catalogId, set);
131
+ signalNotify();
132
+ return true;
133
+ }
101
134
  function reportObjectBounds(catalogId, bounds) {
102
135
  if (bounds === null) {
103
136
  const existed = measuredObjectByCatalogId.delete(catalogId);
@@ -122,6 +155,13 @@ export function createSceneSubsystem(d) {
122
155
  const kind = entities.get(instanceId)?.name;
123
156
  if (kind !== undefined) {
124
157
  const fitted = fittedEntitySetFor(kind);
158
+ // Index-derived mesh shapes win (identical on a headless host); otherwise a runtime-reported
159
+ // collision mesh upgrades the fitted/measured bounding box to the rendered triangles.
160
+ if (fitted !== null && hasMeshColliderShape(fitted))
161
+ return fitted;
162
+ const runtimeMesh = meshEntityByKind.get(kind);
163
+ if (runtimeMesh !== undefined)
164
+ return runtimeMesh;
125
165
  if (fitted !== null)
126
166
  return fitted;
127
167
  const measured = measuredEntityByKind.get(kind);
@@ -149,6 +189,11 @@ export function createSceneSubsystem(d) {
149
189
  if (object === null)
150
190
  return null;
151
191
  const fitted = fittedObjectSetFor(object.catalogId);
192
+ if (fitted !== null && hasMeshColliderShape(fitted))
193
+ return fitted;
194
+ const runtimeMesh = meshObjectByCatalogId.get(object.catalogId);
195
+ if (runtimeMesh !== undefined)
196
+ return runtimeMesh;
152
197
  if (fitted !== null)
153
198
  return fitted;
154
199
  const measured = measuredObjectByCatalogId.get(object.catalogId);
@@ -291,6 +336,7 @@ export function createSceneSubsystem(d) {
291
336
  },
292
337
  collidersOf: objectCollidersOf,
293
338
  reportBounds: reportObjectBounds,
339
+ reportCollisionMesh: reportObjectCollisionMesh,
294
340
  selection: objectSelection,
295
341
  };
296
342
  return {
@@ -310,6 +356,7 @@ export function createSceneSubsystem(d) {
310
356
  entityCollidersOf,
311
357
  objectCollidersOf,
312
358
  reportEntityBounds,
359
+ reportEntityCollisionMesh,
313
360
  entityVisualScaleOf,
314
361
  forms,
315
362
  paintLayer,
@@ -56,7 +56,7 @@ export function createGameContext(options) {
56
56
  ...(occluder !== undefined ? { occluder } : {}),
57
57
  ...(options.models !== undefined ? { models: options.models } : {}),
58
58
  });
59
- const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
59
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, reportEntityCollisionMesh, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
60
60
  // --- Shared game services (weapon, feed, features bag, commands, item use) ---
61
61
  const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
62
62
  const feed = createGameFeed(definition.feed);
@@ -369,6 +369,7 @@ export function createGameContext(options) {
369
369
  },
370
370
  collidersOf: entityCollidersOf,
371
371
  reportBounds: reportEntityBounds,
372
+ reportCollisionMesh: reportEntityCollisionMesh,
372
373
  visualScaleOf: entityVisualScaleOf,
373
374
  form: forms,
374
375
  paint: paintLayer,
@@ -35,6 +35,7 @@ import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
35
35
  import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
36
36
  import type { Forms } from "../scene/form.js";
37
37
  import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
38
+ import type { CollisionMeshSource } from "../scene/collisionMesh.js";
38
39
  import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
39
40
  import type { ObjectStore } from "../scene/objectStore.js";
40
41
  import type { Roster } from "../scene/roster.js";
@@ -145,6 +146,15 @@ export interface SceneObjectContext extends ObjectStore {
145
146
  * @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
146
147
  */
147
148
  reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
149
+ /**
150
+ * Report the entity-local triangle soup the renderer actually mounted for `catalogId` — stored per
151
+ * catalog id as a blocking physical body whose raycasts hit the real surface (a BVH over the
152
+ * triangles) while movement obstruction keeps the conservative AABB. Consulted right after
153
+ * authored `colliders` / index collision meshes and before the fitted-box, measured-bounds, and
154
+ * visual-scale fallbacks. The shell reports rendered models automatically. Pass `null` to clear.
155
+ * Returns `false` when no valid triangle survives.
156
+ */
157
+ reportCollisionMesh(catalogId: string, mesh: CollisionMeshSource | null): boolean;
148
158
  /**
149
159
  * Per-instance selection/highlight state for placed objects — the reactive counterpart to
150
160
  * `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
@@ -275,6 +285,17 @@ export interface SceneEntityContext {
275
285
  * @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
276
286
  */
277
287
  reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
288
+ /**
289
+ * Report the entity-local triangle soup the renderer actually mounted for `kind` — stored per kind
290
+ * as a damage hitbox that raycasts the model's own triangles (BVH), so shots aimed between a
291
+ * character's feet or past its shoulder miss instead of hitting a bounding box. Consulted right
292
+ * after authored `colliders` / index collision meshes and before the fitted-box and
293
+ * measured-bounds fallbacks. The shell reports rendered models automatically; authoritative hosts
294
+ * that never render should author `colliders` or index collision meshes for identical results.
295
+ * Pass `null` to clear. Returns `false` when no valid triangle survives.
296
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — damage raycasts hit the rendered model's triangles, not its bounding box.
297
+ */
298
+ reportCollisionMesh(kind: string, mesh: CollisionMeshSource | null): boolean;
278
299
  /** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
279
300
  visualScaleOf(instanceId: string): number;
280
301
  form: Forms;
@@ -1,5 +1,10 @@
1
1
  import { type ParamSchema } from "./sceneKinds.js";
2
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
2
+ /**
3
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
4
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
5
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
6
+ * scatterable kind; the rest is kind-specific tuning.
7
+ */
3
8
  export declare const SCATTER_SCHEMA: ParamSchema;
4
9
  /** Idempotently registers the engine's built-in environment scene kinds + generators. @internal */
5
10
  export declare function registerBuiltinSceneKinds(): void;
@@ -15,10 +15,17 @@ import { registerSoilKind } from "../world/soilKind.js";
15
15
  import { registerPoleLineKind } from "../world/poleLineKind.js";
16
16
  import { registerBuildingGenerator } from "../world/buildingGenerator.js";
17
17
  import { registerCityKind } from "../world/cityKind.js";
18
+ import { describeScatterCoverage, densityCoverage } from "../world/scatterCoverage.js";
18
19
  import { registerSceneKind } from "./sceneKinds.js";
19
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
20
+ /**
21
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
22
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
23
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
24
+ * scatterable kind; the rest is kind-specific tuning.
25
+ */
20
26
  export const SCATTER_SCHEMA = {
21
27
  fields: [
28
+ { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
22
29
  { type: "range", key: "density", label: "density", min: 0, max: 2, step: 0.01, default: 0.15, unit: "/m²" },
23
30
  { type: "number", key: "minSpacing", label: "spacing", step: 0.25, default: 1.5, min: 0 },
24
31
  { type: "number", key: "minScale", label: "min scale", step: 0.05, default: 0.8, min: 0 },
@@ -27,7 +34,6 @@ export const SCATTER_SCHEMA = {
27
34
  { type: "number", key: "edgeFalloff", label: "edge fade", step: 0.5, default: 0, min: 0 },
28
35
  { type: "bool", key: "alignToNormal", label: "align to slope", default: false },
29
36
  { type: "bool", key: "autoAvoid", label: "auto-avoid gameplay spots", default: true },
30
- { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
31
37
  { type: "seed", key: "seed", label: "seed", default: "" },
32
38
  ],
33
39
  };
@@ -45,6 +51,7 @@ export function registerBuiltinSceneKinds() {
45
51
  addCategory: "Studios",
46
52
  accent: "#34d399",
47
53
  schema: SCATTER_SCHEMA,
54
+ coverage: { spec: "scatter", densityKey: "density", assetsKey: "palette" },
48
55
  note: (object, params) => {
49
56
  const points = object.points ?? [];
50
57
  if (points.length < 3)
@@ -55,7 +62,7 @@ export function registerBuiltinSceneKinds() {
55
62
  }
56
63
  area = Math.abs(area) / 2;
57
64
  const density = typeof params["density"] === "number" ? params["density"] : 0;
58
- return `≈ ${Math.floor(area * density).toLocaleString()} placements over ${Math.round(area).toLocaleString()} m²`;
65
+ return describeScatterCoverage(densityCoverage("scatter", area, density));
59
66
  },
60
67
  });
61
68
  registerWaterKind();
@@ -128,6 +128,26 @@ export declare function measuredEntityColliders(bounds: MeasuredBounds): EntityC
128
128
  * @internal
129
129
  */
130
130
  export declare function measuredObjectColliders(bounds: MeasuredBounds): EntityColliderSet | null;
131
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
132
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
133
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
134
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
135
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
136
+ * @internal
137
+ */
138
+ export declare function meshEntityColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
139
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
140
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
141
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
142
+ * degenerate.
143
+ * @internal
144
+ */
145
+ export declare function meshObjectColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
146
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
147
+ * over runtime-measured ones — they are identical on a headless host).
148
+ * @internal
149
+ */
150
+ export declare function hasMeshColliderShape(set: EntityColliderSet | null | undefined): boolean;
131
151
  /** @internal */
132
152
  export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
133
153
  /** @internal */
@@ -250,6 +250,84 @@ export function measuredObjectColliders(bounds) {
250
250
  },
251
251
  };
252
252
  }
253
+ /** Root-node AABB of a prepared mesh (the BVH root bounds the whole soup) as a clamped
254
+ * entity-local box — `null` when degenerate. */
255
+ function preparedMeshShape(mesh) {
256
+ const bounds = mesh.nodeBounds;
257
+ if (bounds.length < 6)
258
+ return null;
259
+ const min = [bounds[0], bounds[1], bounds[2]];
260
+ const max = [bounds[3], bounds[4], bounds[5]];
261
+ const box = measuredBox({ min, max });
262
+ if (box === null)
263
+ return null;
264
+ return {
265
+ kind: "mesh",
266
+ mesh,
267
+ meshScale: 1,
268
+ meshTranslate: [0, 0, 0],
269
+ halfExtents: box.halfExtents,
270
+ offset: box.offset,
271
+ };
272
+ }
273
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
274
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
275
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
276
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
277
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
278
+ * @internal
279
+ */
280
+ export function meshEntityColliders(mesh) {
281
+ const shape = preparedMeshShape(mesh);
282
+ if (shape === null)
283
+ return null;
284
+ return {
285
+ hitboxes: [
286
+ {
287
+ name: "body",
288
+ purpose: "damage",
289
+ shape,
290
+ damageEligible: true,
291
+ blocks: false,
292
+ },
293
+ ],
294
+ };
295
+ }
296
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
297
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
298
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
299
+ * degenerate.
300
+ * @internal
301
+ */
302
+ export function meshObjectColliders(mesh) {
303
+ const shape = preparedMeshShape(mesh);
304
+ if (shape === null)
305
+ return null;
306
+ return {
307
+ body: {
308
+ name: "body",
309
+ purpose: "physical",
310
+ shape,
311
+ damageEligible: false,
312
+ blocks: true,
313
+ },
314
+ };
315
+ }
316
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
317
+ * over runtime-measured ones — they are identical on a headless host).
318
+ * @internal
319
+ */
320
+ export function hasMeshColliderShape(set) {
321
+ if (set === null || set === undefined)
322
+ return false;
323
+ if (set.body?.shape.kind === "mesh")
324
+ return true;
325
+ for (const hitbox of set.hitboxes ?? []) {
326
+ if (hitbox.shape.kind === "mesh")
327
+ return true;
328
+ }
329
+ return false;
330
+ }
253
331
  /** @internal */
254
332
  export function resolveColliders(set) {
255
333
  if (set === null || set === undefined)
@@ -77,6 +77,14 @@ export declare function voxelizeToBoxes(source: CollisionMeshSource, cellSize?:
77
77
  min: [number, number, number];
78
78
  max: [number, number, number];
79
79
  }[];
80
+ /**
81
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
82
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
83
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
84
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
85
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
86
+ */
87
+ export declare function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null;
80
88
  /**
81
89
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
82
90
  * a headless host preparing the same catalog entry share one build. Returns `null` (also memoized)
@@ -500,6 +500,54 @@ function buildBvh(positions, indices) {
500
500
  triOrder,
501
501
  };
502
502
  }
503
+ /**
504
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
505
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
506
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
507
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
508
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
509
+ */
510
+ export function prepareCollisionMeshSource(source) {
511
+ const raw = source.positions;
512
+ const rawIndices = source.indices;
513
+ if (raw.length < 9 || raw.length % 3 !== 0)
514
+ return null;
515
+ if (rawIndices.length < 3 || rawIndices.length % 3 !== 0)
516
+ return null;
517
+ const vertexCount = raw.length / 3;
518
+ const positions = new Float32Array(raw.length);
519
+ const finite = new Uint8Array(vertexCount);
520
+ for (let v = 0; v < vertexCount; v += 1) {
521
+ const x = raw[v * 3];
522
+ const y = raw[v * 3 + 1];
523
+ const z = raw[v * 3 + 2];
524
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
525
+ continue;
526
+ positions[v * 3] = x;
527
+ positions[v * 3 + 1] = y;
528
+ positions[v * 3 + 2] = z;
529
+ finite[v] = 1;
530
+ }
531
+ const triangles = [];
532
+ for (let t = 0; t + 2 < rawIndices.length; t += 3) {
533
+ const a = rawIndices[t];
534
+ const b = rawIndices[t + 1];
535
+ const c = rawIndices[t + 2];
536
+ if (!Number.isInteger(a) || a < 0 || a >= vertexCount || finite[a] === 0)
537
+ continue;
538
+ if (!Number.isInteger(b) || b < 0 || b >= vertexCount || finite[b] === 0)
539
+ continue;
540
+ if (!Number.isInteger(c) || c < 0 || c >= vertexCount || finite[c] === 0)
541
+ continue;
542
+ if (a === b || b === c || a === c)
543
+ continue;
544
+ triangles.push(a, b, c);
545
+ }
546
+ if (triangles.length === 0)
547
+ return null;
548
+ const indices = new Uint32Array(triangles);
549
+ return { positions, indices, ...buildBvh(positions, indices) };
550
+ }
503
551
  const preparedCache = new WeakMap();
504
552
  /**
505
553
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
@@ -145,6 +145,19 @@ export interface ParamSchema {
145
145
  export declare function findSchemaPreset(schema: ParamSchema, id: string): ParamPreset | undefined;
146
146
  /** Parsed params after `parseParams`: every schema field present with a validated, defaulted value. */
147
147
  export type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]>;
148
+ /**
149
+ * Marks a kind as *scatterable* — it fills an authored area with instances at one density under a
150
+ * bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider
151
+ * + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget
152
+ * from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into
153
+ * the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted
154
+ * "what fills it" palette summarized in the header.
155
+ */
156
+ export interface SceneKindCoverage {
157
+ spec: import("../world/scatterCoverage.js").ScatterCoverageKind;
158
+ densityKey: string;
159
+ assetsKey?: string;
160
+ }
148
161
  /**
149
162
  * Parse a raw `meta` bag against a schema into typed params — every field present, invalid/missing
150
163
  * values replaced by the field default, numbers clamped to their range. The single parser every
@@ -201,6 +214,12 @@ export interface SceneKindDefinition<TResolved = unknown> {
201
214
  resolve?: (object: SceneKindObject, params: ParsedParams, context: SceneKindResolveContext) => TResolved;
202
215
  /** Optional one-line readout under the inspector fields (estimate, dimensions) — owned by the studio. */
203
216
  note?: (object: SceneKindObject, params: ParsedParams) => string;
217
+ /**
218
+ * Optional scatterable-coverage descriptor. When set, the editor leads this kind's inspector with
219
+ * the shared Area / Assets / Density `CoverageSection` and surfaces the budget clamp-and-warn note,
220
+ * giving every scatterable kind (grass/scatter/city) one authoring pattern.
221
+ */
222
+ coverage?: SceneKindCoverage;
204
223
  }
205
224
  /** The raw document object a resolver receives — shape shared by markers, volumes, and paths. */
206
225
  export interface SceneKindObject {
@@ -37,7 +37,22 @@ export interface HudOverflow {
37
37
  }
38
38
  /** Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside). */
39
39
  export declare function rectOverflow(rect: HudRect, viewport: HudSize, tolerance?: number): Omit<HudOverflow, "id"> | null;
40
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
40
+ /**
41
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
42
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
43
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
44
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
45
+ * Only a surface that is partly visible and partly past an edge is a real defect.
46
+ *
47
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
48
+ */
49
+ export declare function rectIntersectsViewport(rect: HudRect, viewport: HudSize, minVisiblePx?: number): boolean;
50
+ /**
51
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
52
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
53
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
54
+ * unopened menus never raise a false alarm.
55
+ */
41
56
  export declare function overflowingPanels(panels: readonly {
42
57
  id: string;
43
58
  rect: HudRect;
@@ -43,10 +43,31 @@ export function rectOverflow(rect, viewport, tolerance = 1.5) {
43
43
  const round = (n) => Math.round(n * 10) / 10;
44
44
  return { left: round(left), top: round(top), right: round(right), bottom: round(bottom) };
45
45
  }
46
- /** Every panel rect that escapes the viewport — the data behind the HUD overflow gate. */
46
+ /**
47
+ * True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
48
+ * The boundary check uses this to tell a *shown* surface from a *closed* one: a
49
+ * mail drawer parked fully off-screen (or a menu that has not been opened) never
50
+ * intersects the viewport, so it is not "escaping" — it is simply not on screen.
51
+ * Only a surface that is partly visible and partly past an edge is a real defect.
52
+ *
53
+ * @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
54
+ */
55
+ export function rectIntersectsViewport(rect, viewport, minVisiblePx = 1) {
56
+ const visibleWidth = Math.min(rect.x + rect.width, viewport.width) - Math.max(rect.x, 0);
57
+ const visibleHeight = Math.min(rect.y + rect.height, viewport.height) - Math.max(rect.y, 0);
58
+ return visibleWidth >= minVisiblePx && visibleHeight >= minVisiblePx;
59
+ }
60
+ /**
61
+ * Every panel rect that escapes the viewport — the data behind the HUD overflow
62
+ * gate. A rect is only reported when it is partly visible *and* crosses an edge;
63
+ * a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
64
+ * unopened menus never raise a false alarm.
65
+ */
47
66
  export function overflowingPanels(panels, viewport, tolerance = 1.5) {
48
67
  const out = [];
49
68
  for (const panel of panels) {
69
+ if (!rectIntersectsViewport(panel.rect, viewport))
70
+ continue;
50
71
  const overflow = rectOverflow(panel.rect, viewport, tolerance);
51
72
  if (overflow !== null)
52
73
  out.push({ id: panel.id, ...overflow });
@@ -32,6 +32,7 @@ import { parseParams, registerSceneKind, } from "../scene/sceneKinds.js";
32
32
  import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_STYLE } from "./buildings.js";
33
33
  import { buildLotPieces, pickClass, pickSpecies, rollClassPlacement, zoneBand, zoneMetric, } from "./cityContent.js";
34
34
  import { furnitureSpots } from "./streets.js";
35
+ import { CITY_BUILDING_BUDGET, budgetWarning, placedCoverage } from "./scatterCoverage.js";
35
36
  import { generateStreets, streetNetworkMode, } from "./streetGenerator.js";
36
37
  import { buildablePolygon, carveCorridors, conformPolygonToRing, cutParcel, extractBlocks, isSliverBlock, pointsInPolygon, RingWalker, } from "./cityBlocks.js";
37
38
  import { fitRectInPolygon, rayDistanceToRing, pointInPolygon, polygonArea, polygonCentroid, polygonMeanWidth, polygonsOverlap, insetRingUniform, ringBounds, } from "./cityGeometry.js";
@@ -567,7 +568,8 @@ export function readCityRules(meta) {
567
568
  }
568
569
  /** Bounded-work caps so a huge volume can never generate unbounded content. */
569
570
  const MAX_STREETS = 320;
570
- const MAX_LOTS = 2600;
571
+ /** Building budget — the shared scatterable cap so the inspector's "capped at N (budget)" note is truthful. */
572
+ const MAX_LOTS = CITY_BUILDING_BUDGET;
571
573
  const MAX_INTERSECTIONS = 600;
572
574
  const MAX_TREES = 3200;
573
575
  const MAX_LIGHTS = 1200;
@@ -1530,6 +1532,7 @@ export function registerCityKind() {
1530
1532
  addCategory: "Studios",
1531
1533
  accent: "#8fa8c9",
1532
1534
  schema: CITY_SCHEMA,
1535
+ coverage: { spec: "city", densityKey: "roadsideOccupancy" },
1533
1536
  resolve: (object, _params, context) => resolveCityObject(object, context),
1534
1537
  note: (object) => {
1535
1538
  const resolved = resolveCityObject(object);
@@ -1558,7 +1561,10 @@ export function registerCityKind() {
1558
1561
  resolved.tunnels.length > 0 ? `${resolved.tunnels.length} tunnels` : "",
1559
1562
  ].filter((s) => s.length > 0);
1560
1563
  const head = mode === "circuit" ? `circuit · ${resolved.streets.length} streets` : `${resolved.streets.length} streets`;
1561
- const fabric = resolved.rules.fabric ? ` · ${resolved.lots.length} buildings · ${resolved.parks.length} parks` : " · no fabric";
1564
+ // Shared clamp-and-warn: when the block/parcel pipeline hits the building budget, surface it in
1565
+ // the same "capped at N (budget)" wording grass and scatter use.
1566
+ const buildingCap = resolved.rules.fabric ? budgetWarning(placedCoverage("city", 0, resolved.lots.length)) : "";
1567
+ const fabric = resolved.rules.fabric ? ` · ${resolved.lots.length} buildings${buildingCap} · ${resolved.parks.length} parks` : " · no fabric";
1562
1568
  return `${head}${fabric}${feats.length > 0 ? ` · ${feats.join(" · ")}` : ""}`;
1563
1569
  },
1564
1570
  });
@@ -8,6 +8,7 @@
8
8
  * @capability grass-field editor-authorable GPU grass patch
9
9
  */
10
10
  import { registerSceneKind } from "../scene/sceneKinds.js";
11
+ import { budgetWarning, densityCoverage } from "./scatterCoverage.js";
11
12
  /** The editor volume kind marking a box as a GPU grass patch. */
12
13
  export const GRASS_FIELD_KIND = "grass_field";
13
14
  /** Grass defaults: a lush, lightly-swaying meadow. */
@@ -89,13 +90,15 @@ export function registerGrassKind() {
89
90
  addCategory: "Studios",
90
91
  accent: "#7fbf4d",
91
92
  schema: GRASS_SCHEMA,
93
+ coverage: { spec: "grass_field", densityKey: "density" },
92
94
  resolve: (object) => resolveGrassObject(object),
93
95
  note: (object) => {
94
96
  const resolved = resolveGrassObject(object);
95
97
  if (resolved === null)
96
98
  return "Give the volume a box footprint.";
97
99
  const area = resolved.size[0] * resolved.size[1];
98
- return `${Math.round(resolved.size[0])}×${Math.round(resolved.size[1])} m · ≈${Math.round(area * resolved.rules.density).toLocaleString()} blades`;
100
+ const coverage = densityCoverage("grass_field", area, resolved.rules.density);
101
+ return `${Math.round(resolved.size[0])}×${Math.round(resolved.size[1])} m · ≈ ${coverage.count.toLocaleString()} blades${budgetWarning(coverage)}`;
99
102
  },
100
103
  });
101
104
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The shared "how much stuff grows here" contract for scatterable scene kinds
3
+ * (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances
4
+ * at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request
5
+ * can never generate unbounded work. Before this module each kind hand-rolled its own readout and
6
+ * clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note);
7
+ * this is the one place that
8
+ *
9
+ * 1. names the per-kind unit noun + placement budget ({@link SCATTER_COVERAGE_SPECS}),
10
+ * 2. turns an area + density into a `requested` / placed `count` / `capped` triple
11
+ * ({@link densityCoverage}, {@link placedCoverage}), and
12
+ * 3. renders the identical clamp-and-warn clause the inspector surfaces —
13
+ * `requested N, capped at M (budget)` — via {@link budgetWarning} / {@link describeScatterCoverage}.
14
+ *
15
+ * Pure data + string helpers, no rendering. Each kind's `note` and the editor's shared
16
+ * `CoverageSection` consume it, so grass, scatter, and city tell one coverage story.
17
+ *
18
+ * @capability scatter-coverage shared density/budget semantics for scatterable kinds
19
+ */
20
+ /** The scatterable kinds that share the coverage contract. */
21
+ export type ScatterCoverageKind = "grass_field" | "scatter" | "city";
22
+ /**
23
+ * Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass
24
+ * renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never
25
+ * renders more than this many placements regardless of density × area.
26
+ */
27
+ export declare const SCATTER_INSTANCE_BUDGET = 250000;
28
+ /** Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap. */
29
+ export declare const CITY_BUILDING_BUDGET = 2600;
30
+ /** Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget. */
31
+ export interface ScatterCoverageSpec {
32
+ kind: ScatterCoverageKind;
33
+ /** Plural noun for one placed instance: `blades` | `placements` | `buildings`. */
34
+ unit: string;
35
+ /** Density is per-square-meter (grass/scatter). City fills by street fabric, not by a per-m² dial. */
36
+ perArea: boolean;
37
+ /** Hard cap on placed instances for one patch/district. */
38
+ budget: number;
39
+ }
40
+ /** The coverage spec per scatterable kind — the single source for its unit + budget. */
41
+ export declare const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec>;
42
+ /** The coverage spec for a scatterable kind, or `null` when the kind is not scatterable. */
43
+ export declare function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null;
44
+ /**
45
+ * A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can
46
+ * be computed cheaply from geometry × density), how many it will actually place after the budget
47
+ * clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is
48
+ * `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the
49
+ * count comes from the resolved street fabric).
50
+ */
51
+ export interface ScatterCoverage {
52
+ kind: ScatterCoverageKind;
53
+ unit: string;
54
+ /** Footprint area in m² (0 when unknown / not applicable). */
55
+ area: number;
56
+ /** Instances the density asks for before the budget clamp; `null` when only the placed count is known. */
57
+ requested: number | null;
58
+ /** Instances actually placed: `min(requested, budget)`, or the generator's reported count. */
59
+ count: number;
60
+ budget: number;
61
+ capped: boolean;
62
+ }
63
+ /**
64
+ * Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box
65
+ * (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor
66
+ * volume, or a scatter path without a hard type dependency.
67
+ */
68
+ export declare function scatterFootprintArea(object: {
69
+ points?: readonly {
70
+ x: number;
71
+ z: number;
72
+ }[];
73
+ halfExtents?: {
74
+ x: number;
75
+ z: number;
76
+ };
77
+ radius?: number;
78
+ }): number;
79
+ /**
80
+ * Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped
81
+ * to the kind's budget. `capped` is true when the request exceeds the budget.
82
+ */
83
+ export declare function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage;
84
+ /**
85
+ * Coverage for a kind whose placed `count` is already known from its generator (city, from its
86
+ * resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so
87
+ * `capped` is inferred from the placed count reaching the ceiling.
88
+ */
89
+ export declare function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage;
90
+ /**
91
+ * The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when
92
+ * under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or
93
+ * ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent"
94
+ * budget signal from #1112.
95
+ */
96
+ export declare function budgetWarning(coverage: ScatterCoverage): string;
97
+ /** The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped. */
98
+ export declare function describeScatterCoverage(coverage: ScatterCoverage): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The shared "how much stuff grows here" contract for scatterable scene kinds
3
+ * (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances
4
+ * at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request
5
+ * can never generate unbounded work. Before this module each kind hand-rolled its own readout and
6
+ * clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note);
7
+ * this is the one place that
8
+ *
9
+ * 1. names the per-kind unit noun + placement budget ({@link SCATTER_COVERAGE_SPECS}),
10
+ * 2. turns an area + density into a `requested` / placed `count` / `capped` triple
11
+ * ({@link densityCoverage}, {@link placedCoverage}), and
12
+ * 3. renders the identical clamp-and-warn clause the inspector surfaces —
13
+ * `requested N, capped at M (budget)` — via {@link budgetWarning} / {@link describeScatterCoverage}.
14
+ *
15
+ * Pure data + string helpers, no rendering. Each kind's `note` and the editor's shared
16
+ * `CoverageSection` consume it, so grass, scatter, and city tell one coverage story.
17
+ *
18
+ * @capability scatter-coverage shared density/budget semantics for scatterable kinds
19
+ */
20
+ /**
21
+ * Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass
22
+ * renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never
23
+ * renders more than this many placements regardless of density × area.
24
+ */
25
+ export const SCATTER_INSTANCE_BUDGET = 250_000;
26
+ /** Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap. */
27
+ export const CITY_BUILDING_BUDGET = 2_600;
28
+ /** The coverage spec per scatterable kind — the single source for its unit + budget. */
29
+ export const SCATTER_COVERAGE_SPECS = {
30
+ grass_field: { kind: "grass_field", unit: "blades", perArea: true, budget: SCATTER_INSTANCE_BUDGET },
31
+ scatter: { kind: "scatter", unit: "placements", perArea: true, budget: SCATTER_INSTANCE_BUDGET },
32
+ city: { kind: "city", unit: "buildings", perArea: false, budget: CITY_BUILDING_BUDGET },
33
+ };
34
+ /** The coverage spec for a scatterable kind, or `null` when the kind is not scatterable. */
35
+ export function scatterCoverageSpec(kind) {
36
+ return SCATTER_COVERAGE_SPECS[kind] ?? null;
37
+ }
38
+ const nonNegative = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
39
+ /**
40
+ * Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box
41
+ * (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor
42
+ * volume, or a scatter path without a hard type dependency.
43
+ */
44
+ export function scatterFootprintArea(object) {
45
+ const points = object.points;
46
+ if (points !== undefined && points.length >= 3) {
47
+ let sum = 0;
48
+ for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
49
+ sum += points[j].x * points[i].z - points[i].x * points[j].z;
50
+ }
51
+ return Math.abs(sum) / 2;
52
+ }
53
+ const he = object.halfExtents;
54
+ if (he !== undefined)
55
+ return nonNegative(Math.abs(he.x) * 2) * nonNegative(Math.abs(he.z) * 2);
56
+ if (object.radius !== undefined)
57
+ return Math.PI * nonNegative(object.radius) ** 2;
58
+ return 0;
59
+ }
60
+ /**
61
+ * Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped
62
+ * to the kind's budget. `capped` is true when the request exceeds the budget.
63
+ */
64
+ export function densityCoverage(kind, area, density) {
65
+ const spec = SCATTER_COVERAGE_SPECS[kind];
66
+ const a = nonNegative(area);
67
+ const requested = Math.floor(nonNegative(density) * a);
68
+ const count = Math.min(requested, spec.budget);
69
+ return { kind, unit: spec.unit, area: a, requested, count, budget: spec.budget, capped: requested > spec.budget };
70
+ }
71
+ /**
72
+ * Coverage for a kind whose placed `count` is already known from its generator (city, from its
73
+ * resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so
74
+ * `capped` is inferred from the placed count reaching the ceiling.
75
+ */
76
+ export function placedCoverage(kind, area, placed) {
77
+ const spec = SCATTER_COVERAGE_SPECS[kind];
78
+ const count = Math.max(0, Math.floor(nonNegative(placed)));
79
+ return { kind, unit: spec.unit, area: nonNegative(area), requested: null, count, budget: spec.budget, capped: count >= spec.budget };
80
+ }
81
+ const fmt = (n) => Math.round(n).toLocaleString();
82
+ /**
83
+ * The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when
84
+ * under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or
85
+ * ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent"
86
+ * budget signal from #1112.
87
+ */
88
+ export function budgetWarning(coverage) {
89
+ if (!coverage.capped)
90
+ return "";
91
+ if (coverage.requested !== null && coverage.requested > coverage.budget) {
92
+ return ` · requested ${fmt(coverage.requested)}, capped at ${fmt(coverage.budget)} (budget)`;
93
+ }
94
+ return ` · capped at ${fmt(coverage.budget)} (budget)`;
95
+ }
96
+ /** The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped. */
97
+ export function describeScatterCoverage(coverage) {
98
+ const head = `≈ ${fmt(coverage.count)} ${coverage.unit}`;
99
+ const area = coverage.area > 0 ? ` · ${fmt(coverage.area)} m²` : "";
100
+ return `${head}${area}${budgetWarning(coverage)}`;
101
+ }
@@ -1,5 +1,6 @@
1
1
  import { masksInfluence, } from "./geometry.js";
2
2
  import { scatter } from "./scatter.js";
3
+ import { SCATTER_INSTANCE_BUDGET } from "./scatterCoverage.js";
3
4
  /** The editor path kind that marks a closed polyline as a foliage/scatter region. */
4
5
  export const SCATTER_PATH_KIND = "scatter";
5
6
  /** Defaults a bare scatter region fills with: sparse grass, lightly spaced. */
@@ -192,6 +193,11 @@ export function resolveScatterRegion(region, terrain, avoid) {
192
193
  scale: rules.minScale + (rules.maxScale - rules.minScale) * scaleRoll,
193
194
  ...(normal === undefined ? {} : { normal }),
194
195
  });
196
+ // Bounded work: a single region never emits more than the shared instance budget, matching the
197
+ // grass patch cap so the inspector's "capped at N (budget)" note is truthful. Placements are
198
+ // generated in deterministic scatter order, so the truncated set is deterministic too.
199
+ if (instances.length >= SCATTER_INSTANCE_BUDGET)
200
+ break;
195
201
  }
196
202
  return instances;
197
203
  }
package/dist/world.d.ts CHANGED
@@ -121,6 +121,7 @@ export { composeRealm } from "./world/realm.js";
121
121
  export { createRegionField, isRegionField, type RegionField } from "./world/regions.js";
122
122
  export { buildRoadRibbon, dashSegments } from "./world/roads.js";
123
123
  export { scatter, type ScatterPoint } from "./world/scatter.js";
124
+ export { CITY_BUILDING_BUDGET, SCATTER_COVERAGE_SPECS, SCATTER_INSTANCE_BUDGET, budgetWarning, densityCoverage, describeScatterCoverage, placedCoverage, scatterCoverageSpec, scatterFootprintArea, type ScatterCoverage, type ScatterCoverageKind, type ScatterCoverageSpec, } from "./world/scatterCoverage.js";
124
125
  export { pickWeighted, scatterItems, type ScatterInstance } from "./world/scatterItems.js";
125
126
  export { SCATTER_PATH_KIND, clearanceZonesFrom, distanceToPolygonEdge, isScatterPath, pointInPolygon, polygonArea, polygonBounds, readScatterPalette, readScatterRules, resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, type ScatterTerrain, } from "./world/scatterRegion.js";
126
127
  export { SOIL_KIND, SOIL_SCHEMA, type SoilRules } from "./world/soilKind.js";
package/dist/world.js CHANGED
@@ -121,6 +121,7 @@ export { composeRealm } from "./world/realm.js";
121
121
  export { createRegionField, isRegionField } from "./world/regions.js";
122
122
  export { buildRoadRibbon, dashSegments } from "./world/roads.js";
123
123
  export { scatter } from "./world/scatter.js";
124
+ export { CITY_BUILDING_BUDGET, SCATTER_COVERAGE_SPECS, SCATTER_INSTANCE_BUDGET, budgetWarning, densityCoverage, describeScatterCoverage, placedCoverage, scatterCoverageSpec, scatterFootprintArea, } from "./world/scatterCoverage.js";
124
125
  export { pickWeighted, scatterItems } from "./world/scatterItems.js";
125
126
  export { SCATTER_PATH_KIND, clearanceZonesFrom, distanceToPolygonEdge, isScatterPath, pointInPolygon, polygonArea, polygonBounds, readScatterPalette, readScatterRules, resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, } from "./world/scatterRegion.js";
126
127
  export { SOIL_KIND, SOIL_SCHEMA } from "./world/soilKind.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jgengine/core",
3
- "version": "0.13.0",
4
- "description": "jgengine core — agent-first game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. https://jgengine.com",
3
+ "version": "0.14.0",
4
+ "description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
5
5
  "keywords": [
6
6
  "jgengine",
7
7
  "agent-first",
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
5
5
 
6
6
  # JGengine intake and router
7
7
 
8
- Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
8
+ Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
9
9
 
10
10
  ## 1. Establish the target
11
11
 
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
20
20
 
21
21
  For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
22
 
23
+ For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
24
+
23
25
  ## 2. Select domains
24
26
 
25
27
  Load only rows the target needs. Do not preload every domain.
@@ -43,7 +45,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
43
45
  Within each selected domain:
44
46
 
45
47
  0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
46
- 1. Search `capabilities.md` by intent; it maps needs to imports.
48
+ 1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
47
49
  2. Open `api.md` only when exact signatures or export inventory are needed.
48
50
  3. Open the linked reference only for a deeper recipe or trap.
49
51
  4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
@@ -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.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -220,7 +220,7 @@
220
220
 
221
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
222
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
223
- - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
224
224
 
225
225
  ## @jgengine/core/runtime/adapter
226
226
 
@@ -925,7 +925,7 @@
925
925
 
926
926
  ## @jgengine/core/scene/builtinSceneKinds
927
927
 
928
- - `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`.
928
+ - `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other scatterable kind; the rest is kind-specific tuning.
929
929
 
930
930
  ## @jgengine/core/scene/captureCheck
931
931
 
@@ -955,6 +955,7 @@
955
955
  - `PreparedCollisionMesh` (interface): interface PreparedCollisionMesh — Decoded, BVH-indexed triangles ready for per-ray queries — build once per asset via {@link prepareCollisionMesh}. Treat the fields as opaque; they exist so the raycast hot path can run allocation-free over flat arrays.
956
956
  - `encodeCollisionMesh` (function): function encodeCollisionMesh(source: CollisionMeshSource): CollisionMeshData | null — Quantize a triangle soup onto the 16-bit grid, weld coincident vertices, and drop triangles that collapse — the reindex-time compressor producing {@link CollisionMeshData}. Returns `null` when no finite, non-degenerate triangle survives.
957
957
  - `prepareCollisionMesh` (function): function prepareCollisionMesh(data: CollisionMeshData): PreparedCollisionMesh | null — Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and a headless host preparing the same catalog entry share one build. Returns `null` (also memoized) when the payload is malformed.
958
+ - `prepareCollisionMeshSource` (function): function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null — Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) — the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range, non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
958
959
  - `raycastCollisionMesh` (function): function raycastCollisionMesh(mesh: PreparedCollisionMesh, origin: EntityPosition, direction: EntityPosition, maxDistance: number, position: EntityPosition, rotationY: number, scale: number, translate: EntityPosition): CollisionMeshHit | null — Nearest triangle hit for a world-space ray against a prepared mesh placed like its rendered model: model space scaled uniformly by `scale`, translated by entity-local `translate`, yaw-rotated by `rotationY`, and moved to `position` — the same composition {@link "scene/colliders".ModelBodySource} fitting applies to the AABB. Allocation-free until the returned hit. `distance` is world distance along `direction` (callers pass `direction` normalized); `normal` is the world-space geometric triangle normal facing the ray.
959
960
 
960
961
  ## @jgengine/core/scene/companion
@@ -1098,6 +1099,7 @@
1098
1099
  - `ParamSchema` (interface): interface ParamSchema — A kind's full parameter surface: an ordered list of fields the inspector renders top-to-bottom.
1099
1100
  - `ParsedParams` (type): type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]> — Parsed params after `parseParams`: every schema field present with a validated, defaulted value.
1100
1101
  - `RangeParamField` (interface): interface RangeParamField — A numeric slider row: bounded range with a live readout.
1102
+ - `SceneKindCoverage` (interface): interface SceneKindCoverage — Marks a kind as *scatterable* — it fills an authored area with instances at one density under a bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted "what fills it" palette summarized in the header.
1101
1103
  - `SceneKindDefinition` (interface): interface SceneKindDefinition<TResolved = unknown> — A registered scene kind — everything the editor/engine need to author and render a studio without bespoke code. `schema` drives the inspector + parse; `resolve` (optional) turns one document object into pure renderable data a matching `shell` renderer consumes; `add*` fields build the `+ Add` menu.
1102
1104
  - `SceneKindObject` (interface): interface SceneKindObject — The raw document object a resolver receives — shape shared by markers, volumes, and paths.
1103
1105
  - `SceneKindResolveContext` (interface): interface SceneKindResolveContext — Ground sampler + options a resolver may read (terrain height/normal snap).
@@ -1453,6 +1455,7 @@
1453
1455
  - `BuildingIndex` (interface): interface BuildingIndex — ⚠ undocumented
1454
1456
  - `BuildingPaletteOverrides` (type): type BuildingPaletteOverrides = Partial<BuildingPalette> — ⚠ undocumented
1455
1457
  - `BuildingStyle` (type): type BuildingStyle = | "generic" | "capital" | "village" | "desert" | "industrial" | "coastal" | "neon" | "ruin" | "frontier" | "aerial" — ⚠ undocumented
1458
+ - `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
1456
1459
  - `CITY_DEFAULTS` (const): const CITY_DEFAULTS: CityRules — City defaults: a zoned mixed metropolis — towers downtown, slabs mid-ring, houses at the edge.
1457
1460
  - `CITY_KIND` (const): const CITY_KIND: "city" — The editor volume kind marking a box as a procedural city district.
1458
1461
  - `CITY_LOT_CLASSES` (const): const CITY_LOT_CLASSES: readonly CityLotClass[] — All classes, for schema hints and validation.
@@ -1697,6 +1700,8 @@
1697
1700
  - `RosterEntry` (interface): interface RosterEntry — ⚠ undocumented
1698
1701
  - `RoundGround` (interface): interface RoundGround — A planet/sphere you play on the outside of. Sized by `radius` only.
1699
1702
  - `RoundGroundSize` (interface): interface RoundGroundSize — Size of a `round` ground: the planet/sphere radius in world units. Nothing else.
1703
+ - `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
1704
+ - `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
1700
1705
  - `SCATTER_PATH_KIND` (const): const SCATTER_PATH_KIND: "scatter" — The editor path kind that marks a closed polyline as a foliage/scatter region.
1701
1706
  - `SHAPE_BOX` (const): const SHAPE_BOX: 0 — ⚠ undocumented
1702
1707
  - `SHAPE_SPHERE` (const): const SHAPE_SPHERE: 1 — ⚠ undocumented
@@ -1710,6 +1715,9 @@
1710
1715
  - `SampleReason` (type): type SampleReason = "accepted" | "exhausted" | "empty-region" — Why a sample ended: a clean hit, budget exhausted, or the region was degenerate.
1711
1716
  - `SampleRegion` (interface): interface SampleRegion<P extends SamplePoint = SamplePoint> — A geometric region a sampler can draw from. Built-ins cover point sets, rect/box, circle/sphere, annulus/shell, polygon, and weighted composites; {@link customRegion} wraps a caller-defined sampler + bounds. `sample` draws one raw candidate under the region's own distribution policy; `contains` is the membership test reused for include/exclude constraints and rejection.
1712
1717
  - `SampleResult` (interface): interface SampleResult<P extends SamplePoint = SamplePoint> — Structured single-sample outcome — never a bare point, so failure and fallback are visible.
1718
+ - `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
1719
+ - `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
1720
+ - `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
1713
1721
  - `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
1714
1722
  - `ScatterPoint` (interface): interface ScatterPoint — ⚠ undocumented
1715
1723
  - `ScatterTerrain` (interface): interface ScatterTerrain — Ground sampler a scatter resolve reads height/normal from (the sculpt terrain or the game's ground).
@@ -1848,6 +1856,7 @@
1848
1856
  - `boundaryNeighbors` (function): function boundaryNeighbors(grid: FootprintGrid, cells: readonly GridCell[]): AdjacentCell[] — Every occupied cell orthogonally touching `cells` but outside them — the connective-piece neighbor set.
1849
1857
  - `boxFormation` (function): function boxFormation(options: BoxFormationOptions): FormationSlotGenerator — A rectangular grid centered on the destination, front row toward `+forward` — a marching block or a phalanx. Rows fill front-to-back, left-to-right.
1850
1858
  - `boxRegion` (function): function boxRegion(min: Point3, max: Point3): SampleRegion<Point3> — An axis-aligned box `[min..max]` in 3D. Uniform density; draws x, y, z in order.
1859
+ - `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
1851
1860
  - `buildContextMenu` (function): function buildContextMenu(input: BuildContextMenuInput): ContextMenu | null — Assemble a menu from a target's catalog verbs; null when the target lists none.
1852
1861
  - `buildRoadRibbon` (function): function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options: RoadRibbonOptions = {}): RoadRibbon — Triangulate a road centerline into a ground-draped ribbon mesh: the polyline is subdivided, each vertex is offset half a `width` along the local perpendicular, and every vertex sits at `sampleHeight(x, z) + elevation`. Pure geometry — the shell (or any renderer) turns the result into a mesh, and tests can assert on it directly.
1853
1862
  - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
@@ -1929,6 +1938,8 @@
1929
1938
  - `definePatrolOrder` (function): function definePatrolOrder<TCtx extends OrderMover>(config: OrderKindConfig & { arriveRadius?: number } = {}): OrderKind<TCtx, PatrolOrderPayload> — Patrol: walk a waypoint route, advancing the serialized `index` as each point is reached. Loops forever by default, or completes at the last waypoint when `loop` is `false`.
1930
1939
  - `defineStopOrder` (function): function defineStopOrder<TCtx extends OrderMover>(config: OrderKindConfig = {}): OrderKind<TCtx, EmptyOrderPayload> — Stop: halt immediately and complete the same tick — the interrupt that clears a unit's intent to standstill.
1931
1940
  - `defineTargetedOrder` (function): function defineTargetedOrder<TCtx extends OrderMover & OrderTargeting>(config: EngagementKindConfig = {}): OrderKind<TCtx, TargetedOrderPayload> — Targeted action: pursue one specific target until within `range`, writing the engagement intent into `order.state`. Completes when the target is gone (despawned/dead), so the game's effect loop runs while `inRange` is true.
1941
+ - `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
1942
+ - `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
1932
1943
  - `discRegion` (function): function discRegion(center: Vec2, radius: number, options: { distribution?: RadialDistribution } = {}): SampleRegion<Vec2> — A filled circle. `"area"` is area-uniform (√-corrected radius, even density); `"radial"` is radius-uniform (clumps toward the center). Angle is drawn first, then radius — matching the common `angle = rng()·2π; r = rng()·R` idiom so migrations keep their stream.
1933
1944
  - `distance` (function): function distance(a: Vec3, b: Vec3): number — ⚠ undocumented
1934
1945
  - `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
@@ -2002,6 +2013,7 @@
2002
2013
  - `placeAuthoredObjects` (function): function placeAuthoredObjects(store: AuthoredObjectPlaceTarget, objects: readonly AuthoredObject[], sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Places resolved authored objects into an object store, grounding each on `sampleHeight(x,z)` plus per-object and options vertical offsets. Returns the instance ids that were placed (or kept).
2003
2014
  - `placeAuthoredObjectsFromDocument` (function): function placeAuthoredObjectsFromDocument(store: AuthoredObjectPlaceTarget, document: AuthoredObjectsDocumentLike, sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Convenience: resolve a document then place every authored catalog prop.
2004
2015
  - `placeFormation` (function): function placeFormation(destination: Vec2, facing: number, count: number, generator: FormationSlotGenerator): Vec2[] — Transform a generator's local slot offsets into world XZ positions around a `destination`, rotated by `facing` (engine yaw). Slot `i` in the returned array is `generator(count)[i]` mapped through the group frame, so it stays aligned with {@link assignFormationSlots}' slot indices. Pure and allocation- light: one array of `count` points, no per-call closures retained.
2016
+ - `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
2005
2017
  - `player` (function): function player(): PlayerBehavior — ⚠ undocumented
2006
2018
  - `plots` (function): function plots(config: PlotsWorldConfig = {}): WorldFeature — Declares a subdivided-plots world — farming, base-building, and other parcel-based layouts.
2007
2019
  - `pointGravity` (function): function pointGravity(options: PointGravityOptions): GravityField — Radial gravity toward one center, with optional inverse-square falloff for planetary worlds.
@@ -2057,6 +2069,8 @@
2057
2069
  - `sampleStratified` (function): function sampleStratified(options: StratifiedOptions): Vec2[] — Stratified 2D sampling: one jittered point per grid cell over `area`, in deterministic row-major order. Unlike pure rejection sampling this guarantees even coverage (no clumps, no gaps) — the distinct "stratified" distribution policy, kept separate from area-uniform draws.
2058
2070
  - `sanitizeGameTimeScale` (function): function sanitizeGameTimeScale(timeScale?: number | null): number — ⚠ undocumented
2059
2071
  - `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
2072
+ - `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
2073
+ - `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
2060
2074
  - `scatterItems` (function): function scatterItems<T>(field: RegionField<T>, area: Aabb, layersFor: (sample: RegionSample<T>) => readonly ScatterLayer[], options: { cell?: number; max?: number; saltKey?: number } = {}): ScatterInstance[] — Deterministically place opaque items across `area`, grounded on a region field. For each grid cell it asks `layersFor` which items may appear in that region and rolls one against their densities. The engine never interprets `item` — a game maps it to a mesh or entity. Content scatter (region-driven density) as opposed to `scatter` in `./scatter`, which is renderer-free geometric point distribution.
2061
2075
  - `scatterRegionEstimate` (function): function scatterRegionEstimate(path: ScenePathLike): { area: number; count: number } — Estimated placement count for a scatter path — density × polygon area, for a live UI readout.
2062
2076
  - `scatterRegionFromPath` (function): function scatterRegionFromPath(path: ScenePathLike): ScatterRegion | null — Builds a resolvable {@link ScatterRegion} from a scatter path (XZ polygon + rules), or null.
@@ -2750,6 +2764,21 @@
2750
2764
  - `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
2751
2765
  - `scatterAabb` (function): function scatterAabb(area: ScatterArea | Aabb): Aabb — ⚠ undocumented
2752
2766
 
2767
+ ## @jgengine/core/world/scatterCoverage
2768
+
2769
+ - `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
2770
+ - `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
2771
+ - `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
2772
+ - `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
2773
+ - `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
2774
+ - `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
2775
+ - `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
2776
+ - `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
2777
+ - `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
2778
+ - `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
2779
+ - `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
2780
+ - `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
2781
+
2753
2782
  ## @jgengine/core/world/scatterItems
2754
2783
 
2755
2784
  - `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
@@ -355,6 +355,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
355
355
  - `controlGroupKey` (function) · `import { controlGroupKey } from "@jgengine/core/world"`
356
356
  - `resolveControlGroupIntent` (function) · `import { resolveControlGroupIntent } from "@jgengine/core/world"`
357
357
 
358
+ ## scatter-coverage — shared density/budget semantics for scatterable kinds
359
+
360
+ - `ScatterCoverageKind` (type) · `import { ScatterCoverageKind } from "@jgengine/core/world"`
361
+
358
362
  ## selection-bookmark-recall — fold a saved set into the active selection with stale-ref pruning and a caller focus hook
359
363
 
360
364
  - `recallSelectionBookmark` (function) · `import { recallSelectionBookmark } from "@jgengine/core/world"`