@jgengine/core 0.12.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +45 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +4 -1
  29. package/dist/runtime/context/scene.js +86 -1
  30. package/dist/runtime/gameContext.js +51 -4
  31. package/dist/runtime/gameContextTypes.d.ts +58 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  36. package/dist/scene/builtinSceneKinds.js +10 -3
  37. package/dist/scene/colliders.d.ts +41 -0
  38. package/dist/scene/colliders.js +140 -0
  39. package/dist/scene/collisionMesh.d.ts +8 -0
  40. package/dist/scene/collisionMesh.js +48 -0
  41. package/dist/scene/entityStats.js +9 -1
  42. package/dist/scene/sceneKinds.d.ts +19 -0
  43. package/dist/stats/statModifiers.d.ts +16 -0
  44. package/dist/stats/statModifiers.js +30 -1
  45. package/dist/stats/statPool.js +5 -2
  46. package/dist/ui/hudScale.d.ts +16 -1
  47. package/dist/ui/hudScale.js +22 -1
  48. package/dist/ui/panelModel.d.ts +120 -0
  49. package/dist/ui/panelModel.js +168 -0
  50. package/dist/ui.d.ts +1 -0
  51. package/dist/ui.js +1 -0
  52. package/dist/world/buildingLots.d.ts +84 -0
  53. package/dist/world/buildingLots.js +119 -0
  54. package/dist/world/buildings.d.ts +6 -0
  55. package/dist/world/cityGenerator.d.ts +41 -0
  56. package/dist/world/cityGenerator.js +58 -0
  57. package/dist/world/cityKind.d.ts +2 -2
  58. package/dist/world/cityKind.js +12 -6
  59. package/dist/world/environmentSummary.js +57 -1
  60. package/dist/world/features.d.ts +35 -2
  61. package/dist/world/features.js +5 -2
  62. package/dist/world/grassKind.js +4 -1
  63. package/dist/world/roads.d.ts +19 -2
  64. package/dist/world/roads.js +60 -3
  65. package/dist/world/scatterCoverage.d.ts +98 -0
  66. package/dist/world/scatterCoverage.js +101 -0
  67. package/dist/world/scatterRegion.js +6 -0
  68. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  69. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  70. package/dist/world/streets.d.ts +74 -1
  71. package/dist/world/streets.js +218 -0
  72. package/dist/world/terrain.d.ts +20 -1
  73. package/dist/world/terrain.js +41 -2
  74. package/dist/world.d.ts +1 -0
  75. package/dist/world.js +1 -0
  76. package/package.json +2 -2
  77. package/skills/jgengine/SKILL.md +5 -2
  78. package/skills/jgengine/api.md +13 -5
  79. package/skills/jgengine-combat/SKILL.md +15 -1
  80. package/skills/jgengine-combat/api.md +39 -2
  81. package/skills/jgengine-combat/capabilities.md +17 -1
  82. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  83. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  84. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  85. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  86. package/skills/jgengine-combat/reference.md +22 -3
  87. package/skills/jgengine-gameplay/SKILL.md +8 -0
  88. package/skills/jgengine-gameplay/api.md +35 -5
  89. package/skills/jgengine-gameplay/capabilities.md +18 -0
  90. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  91. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  92. package/skills/jgengine-world/api.md +82 -25
  93. package/skills/jgengine-world/capabilities.md +12 -4
  94. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,119 @@
1
+ const DEFAULT_FOOTPRINT = { w: 12, d: 10 };
2
+ /** Cumulative arc length of a polyline. */
3
+ function polylineLength(path) {
4
+ let sum = 0;
5
+ for (let i = 0; i + 1 < path.length; i += 1) {
6
+ sum += Math.hypot(path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1]);
7
+ }
8
+ return sum;
9
+ }
10
+ /** Point + unit tangent at arc length `s` along a polyline (clamped to its ends). */
11
+ function stationAt(path, s) {
12
+ let acc = 0;
13
+ for (let i = 0; i + 1 < path.length; i += 1) {
14
+ const a = path[i];
15
+ const b = path[i + 1];
16
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
17
+ if (len < 1e-9)
18
+ continue;
19
+ if (acc + len >= s || i + 2 === path.length) {
20
+ const t = Math.max(0, Math.min(1, (s - acc) / len));
21
+ return {
22
+ p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t],
23
+ tangent: [(b[0] - a[0]) / len, (b[1] - a[1]) / len],
24
+ };
25
+ }
26
+ acc += len;
27
+ }
28
+ const a = path[0];
29
+ return { p: [a[0], a[1]], tangent: [1, 0] };
30
+ }
31
+ function inArea(area, x, z) {
32
+ if (area === undefined)
33
+ return true;
34
+ const [cx, cz] = area.center ?? [0, 0];
35
+ return Math.abs(x - cx) <= area.halfExtents[0] && Math.abs(z - cz) <= area.halfExtents[1];
36
+ }
37
+ /**
38
+ * Yaw that turns the building FRONT (local +z) to face the road. The lot center sits at
39
+ * `roadPoint + outwardNormal * d`, so the road lies along `-outwardNormal` from the center. In the
40
+ * engine's Y-rotation convention a local +z of `(0, 1)` maps to world `(sin y, cos y)`; setting
41
+ * that equal to `-outwardNormal` gives `y = atan2(-nx, -nz)`.
42
+ * @internal
43
+ */
44
+ export function facingRotation(outwardNormal) {
45
+ return Math.atan2(-outwardNormal[0], -outwardNormal[1]);
46
+ }
47
+ /**
48
+ * Derive street-aligned building lots from road frontage. Lots are stepped along each road at
49
+ * `footprint.w + spacing`, offset to each side by `width / 2 + setback + footprint.d / 2`, and
50
+ * turned to face the road. Cross-road overlaps are rejected by a bounded nearest-neighbour test, so
51
+ * two roads meeting at a corner don't stack buildings. Deterministic: identical inputs (including
52
+ * seed) always yield the identical list.
53
+ * @capability building-lots derive street-facing building lots from road frontage
54
+ */
55
+ export function deriveBuildingLots(options) {
56
+ const footprint = options.footprint ?? DEFAULT_FOOTPRINT;
57
+ const spacing = Math.max(0, options.spacing ?? 2);
58
+ const setback = Math.max(0, options.setback ?? 3);
59
+ const bothSides = options.bothSides ?? true;
60
+ const maxLots = Math.max(0, Math.floor(options.maxLots ?? 400));
61
+ const step = Math.max(1, footprint.w + spacing);
62
+ const sides = bothSides ? [1, -1] : [1];
63
+ // Minimum center separation for cross-road de-dup: half the footprint diagonal, a touch under
64
+ // the along-road step so same-road neighbours are never rejected.
65
+ const minSep = Math.min(step * 0.95, Math.hypot(footprint.w, footprint.d) * 0.5);
66
+ const minSep2 = minSep * minSep;
67
+ const lots = [];
68
+ const accepted = [];
69
+ for (let r = 0; r < options.roads.length && lots.length < maxLots; r += 1) {
70
+ const road = options.roads[r];
71
+ if (road.path.length < 2)
72
+ continue;
73
+ const total = polylineLength(road.path);
74
+ if (total < 1e-6)
75
+ continue;
76
+ const front = road.width / 2 + setback;
77
+ const offset = front + footprint.d / 2;
78
+ // Center the run so lots sit symmetrically and endpoints keep a half-step margin.
79
+ const count = Math.max(1, Math.floor(total / step));
80
+ const startS = (total - (count - 1) * step) / 2;
81
+ for (let i = 0; i < count && lots.length < maxLots; i += 1) {
82
+ const s = startS + i * step;
83
+ const station = stationAt(road.path, s);
84
+ const [tx, tz] = station.tangent;
85
+ // Left normal of the tangent (CCW): (-tz, tx). Right side negates it.
86
+ for (const side of sides) {
87
+ if (lots.length >= maxLots)
88
+ break;
89
+ const nx = -tz * side;
90
+ const nz = tx * side;
91
+ const cx = station.p[0] + nx * offset;
92
+ const cz = station.p[1] + nz * offset;
93
+ if (!inArea(options.area, cx, cz))
94
+ continue;
95
+ let clash = false;
96
+ for (const [ax, az] of accepted) {
97
+ const dx = ax - cx;
98
+ const dz = az - cz;
99
+ if (dx * dx + dz * dz < minSep2) {
100
+ clash = true;
101
+ break;
102
+ }
103
+ }
104
+ if (clash)
105
+ continue;
106
+ lots.push({
107
+ center: [cx, cz],
108
+ rotationY: facingRotation([nx, nz]),
109
+ footprint: { w: footprint.w, d: footprint.d },
110
+ road: r,
111
+ side,
112
+ frontDistance: front,
113
+ });
114
+ accepted.push([cx, cz]);
115
+ }
116
+ }
117
+ }
118
+ return lots;
119
+ }
@@ -72,6 +72,12 @@ export interface GeneratedBuilding {
72
72
  floorHeight: number;
73
73
  bounds: Aabb;
74
74
  parts: readonly BuildingPartPlacement[];
75
+ /**
76
+ * Optional building yaw (radians) about `center`, applied by the renderer to the whole massing —
77
+ * used by street-aware placement to turn a building's front to face its road. Absent/0 keeps the
78
+ * axis-aligned grid orientation.
79
+ */
80
+ rotationY?: number;
75
81
  }
76
82
  export interface BuildingLot {
77
83
  id: string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The seed-driven procedural CITY GENERATOR — the composition seam over the two fabric engines:
3
+ * {@link generateStreets} grows the street network, and {@link deriveBuildingLots} lines its
4
+ * frontage with street-facing building lots. One call, one seed, one deterministic city: same rules
5
+ * ⇒ identical streets and identical lots, with both engines' bounded-work caps intact. Local
6
+ * (volume-centered) coords, same as the street generator; the caller translates into world space.
7
+ *
8
+ * Street dials default to a dense, gently-varied city net (high gridness/connectivity, light
9
+ * winding); pass any subset of {@link StreetNetworkRules} to override, and lot options pass through
10
+ * to the frontage engine untouched. Lanes are skipped as lot frontage by default — buildings line
11
+ * boulevards, avenues, and streets — so alleys stay service alleys; opt lanes in via
12
+ * `lots.laneFrontage`.
13
+ *
14
+ * @capability city-generator compose a deterministic city — street network plus street-facing building lots — from one seed
15
+ */
16
+ import { type BuildingLotOptions, type PlacedBuildingLot } from "./buildingLots.js";
17
+ import { type StreetNetwork, type StreetNetworkContext, type StreetNetworkRules } from "./streetGenerator.js";
18
+ /** Options for {@link generateCity}: a seed, street-dial overrides, and lot pass-through options. */
19
+ export interface CityGeneratorOptions {
20
+ /** Drives both the street network and downstream lot/floor variation. */
21
+ seed: string;
22
+ /** Street-dial overrides; anything omitted takes the city defaults above. */
23
+ streets?: Partial<Omit<StreetNetworkRules, "seed">>;
24
+ /** Frontage-lot options, minus `roads` (the generated streets are the roads). */
25
+ lots?: Omit<BuildingLotOptions, "roads" | "seed"> & {
26
+ /** Also line `lane`-level streets (alleys) with lots. Default false. */
27
+ laneFrontage?: boolean;
28
+ };
29
+ /** Ground sampler + bridge/tunnel toggles forwarded to the street generator. */
30
+ context?: StreetNetworkContext;
31
+ }
32
+ /** A generated city: the street network and the building lots lining its frontage. */
33
+ export interface GeneratedCity {
34
+ network: StreetNetwork;
35
+ lots: PlacedBuildingLot[];
36
+ }
37
+ /**
38
+ * Grow a street network inside the `hx`/`hz` half-extents and line its frontage with building lots.
39
+ * Deterministic: identical options ⇒ identical city.
40
+ */
41
+ export declare function generateCity(options: CityGeneratorOptions, hx: number, hz: number): GeneratedCity;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The seed-driven procedural CITY GENERATOR — the composition seam over the two fabric engines:
3
+ * {@link generateStreets} grows the street network, and {@link deriveBuildingLots} lines its
4
+ * frontage with street-facing building lots. One call, one seed, one deterministic city: same rules
5
+ * ⇒ identical streets and identical lots, with both engines' bounded-work caps intact. Local
6
+ * (volume-centered) coords, same as the street generator; the caller translates into world space.
7
+ *
8
+ * Street dials default to a dense, gently-varied city net (high gridness/connectivity, light
9
+ * winding); pass any subset of {@link StreetNetworkRules} to override, and lot options pass through
10
+ * to the frontage engine untouched. Lanes are skipped as lot frontage by default — buildings line
11
+ * boulevards, avenues, and streets — so alleys stay service alleys; opt lanes in via
12
+ * `lots.laneFrontage`.
13
+ *
14
+ * @capability city-generator compose a deterministic city — street network plus street-facing building lots — from one seed
15
+ */
16
+ import { deriveBuildingLots } from "./buildingLots.js";
17
+ import { isOnRoad } from "./roads.js";
18
+ import { generateStreets, } from "./streetGenerator.js";
19
+ /** City-shaped defaults for every street dial except the seed. */
20
+ const CITY_STREET_DEFAULTS = {
21
+ gridness: 0.85,
22
+ loopiness: 0.35,
23
+ connectivity: 0.6,
24
+ branching: 0.25,
25
+ deadEnds: 0.15,
26
+ segmentLength: 90,
27
+ aspect: 1.4,
28
+ winding: 0.15,
29
+ minCurveRadius: 18,
30
+ minTurnAngle: 12,
31
+ maxTurnAngle: 110,
32
+ width: 9,
33
+ boulevards: 0.2,
34
+ };
35
+ /**
36
+ * Grow a street network inside the `hx`/`hz` half-extents and line its frontage with building lots.
37
+ * Deterministic: identical options ⇒ identical city.
38
+ */
39
+ export function generateCity(options, hx, hz) {
40
+ const { seed, streets: streetOverrides, lots: lotOptions, context } = options;
41
+ const rules = { seed, ...CITY_STREET_DEFAULTS, ...streetOverrides };
42
+ const network = generateStreets(rules, hx, hz, context ?? {});
43
+ const { laneFrontage = false, ...lotRest } = lotOptions ?? {};
44
+ const frontage = network.streets
45
+ .filter((street) => laneFrontage || street.level !== "lane")
46
+ .map((street) => ({ path: street.points, width: street.width }));
47
+ const candidates = deriveBuildingLots({
48
+ ...lotRest,
49
+ roads: frontage,
50
+ seed,
51
+ area: lotRest.area ?? { center: [0, 0], halfExtents: [hx, hz] },
52
+ });
53
+ // The frontage engine only avoids the roads it lines; streets excluded from frontage (lanes by
54
+ // default) are still pavement, so drop any lot whose footprint would straddle one.
55
+ const clearance = Math.min(lotRest.footprint?.w ?? 12, lotRest.footprint?.d ?? 10);
56
+ const lots = candidates.filter((lot) => network.streets.every((street) => !isOnRoad(street.points, street.width + clearance, lot.center[0], lot.center[1])));
57
+ return { network, lots };
58
+ }
@@ -2,7 +2,7 @@ import { type ParamSchema, type SceneKindObject, type SceneKindResolveContext, t
2
2
  import { type BuildingStyle } from "./buildings.js";
3
3
  import { type CityLotClass, type CityLotPiece, type CityTreeSpecies, type CityZoneBand, type CityZoneProfile } from "./cityContent.js";
4
4
  import type { RoadPoint } from "./roads.js";
5
- import { type PathFeatureKind } from "./pathNetwork.js";
5
+ import { type StreetFeatureKind } from "./streetGenerator.js";
6
6
  import { type CityBlockKind } from "./cityBlocks.js";
7
7
  /** The editor volume kind marking a box as a procedural city district. */
8
8
  export declare const CITY_KIND = "city";
@@ -130,7 +130,7 @@ export interface CityStreet {
130
130
  bulb?: RoadPoint;
131
131
  /** Bridge/tunnel spans along this street as `[from, to]` index windows into `points`. */
132
132
  features?: readonly {
133
- kind: PathFeatureKind;
133
+ kind: StreetFeatureKind;
134
134
  from: number;
135
135
  to: number;
136
136
  bankHeight: number;
@@ -3,7 +3,7 @@
3
3
  * the whole synthesis, from a rigid Manhattan grid (`gridness` 1, `curviness` 0) to winding
4
4
  * hillside estates, a two-road crossroads farm town, or — at the loopiness-high / branching-and-
5
5
  * connectivity-low corner — a closed RACE CIRCUIT. Streets come from the unified seed-driven
6
- * {@link buildPathNetwork} engine (city and track are the same generator at opposite slider
6
+ * {@link generateStreets} engine (city and track are the same generator at opposite slider
7
7
  * extremes), carry a visible hierarchy (boulevards with medians, avenues, streets, lanes —
8
8
  * optionally gravel), and connect or deliberately dead-end into cul-de-sac bulbs; water gaps become
9
9
  * styled bridge decks and ridges become tunnel bores, both path features on continuous streets so a
@@ -32,7 +32,8 @@ 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 { buildPathNetwork, pathNetworkMode, } from "./pathNetwork.js";
35
+ import { CITY_BUILDING_BUDGET, budgetWarning, placedCoverage } from "./scatterCoverage.js";
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";
38
39
  /** The editor volume kind marking a box as a procedural city district. */
@@ -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;
@@ -1215,7 +1217,7 @@ export function resolveCityObject(object, context) {
1215
1217
  width: rules.streetWidth,
1216
1218
  boulevards: rules.boulevards,
1217
1219
  };
1218
- const network = buildPathNetwork(netRules, hx, hz, {
1220
+ const network = generateStreets(netRules, hx, hz, {
1219
1221
  heightAt: heightAt ?? undefined,
1220
1222
  minElevation: rules.minElevation,
1221
1223
  bridges: rules.bridges,
@@ -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);
@@ -1537,7 +1540,7 @@ export function registerCityKind() {
1537
1540
  return "Give the volume a box footprint.";
1538
1541
  if (resolved.streets.length === 0)
1539
1542
  return `Footprint too small — needs at least ${Math.ceil(resolved.rules.blockSize * 1.5)} m across.`;
1540
- const mode = pathNetworkMode({
1543
+ const mode = streetNetworkMode({
1541
1544
  seed: resolved.rules.seed,
1542
1545
  gridness: resolved.rules.gridness,
1543
1546
  loopiness: resolved.rules.loopiness,
@@ -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
  });
@@ -1,7 +1,11 @@
1
- import { generateBuildingDistrict, resolveBuildingPalette, } from "./buildings.js";
1
+ import { DEFAULT_BUILDING_CONFIG, generateBuilding, generateBuildingDistrict, resolveBuildingPalette, } from "./buildings.js";
2
+ import { deriveBuildingLots } from "./buildingLots.js";
2
3
  import { pathLength } from "./roads.js";
3
4
  import { resolveTerrainField, resolveTerrainPalette } from "./terrain.js";
4
5
  export function resolveStructureBuildings(descriptor) {
6
+ if (descriptor.along !== undefined) {
7
+ return resolveFrontageBuildings(descriptor, descriptor.along);
8
+ }
5
9
  const columns = Math.max(1, Math.ceil(Math.sqrt(descriptor.count)));
6
10
  const rows = Math.max(1, Math.ceil(descriptor.count / columns));
7
11
  const spacing = descriptor.spacing;
@@ -21,6 +25,58 @@ export function resolveStructureBuildings(descriptor) {
21
25
  ...(descriptor.seed === undefined ? {} : { seed: descriptor.seed }),
22
26
  }).slice(0, descriptor.count);
23
27
  }
28
+ function hashU32(input) {
29
+ let hash = 2166136261;
30
+ for (let i = 0; i < input.length; i += 1) {
31
+ hash ^= input.charCodeAt(i);
32
+ hash = Math.imul(hash, 16777619);
33
+ }
34
+ hash ^= hash >>> 16;
35
+ hash = Math.imul(hash, 2246822507);
36
+ hash ^= hash >>> 13;
37
+ hash = Math.imul(hash, 3266489909);
38
+ hash ^= hash >>> 16;
39
+ return hash >>> 0;
40
+ }
41
+ /**
42
+ * Build one street-aware building per road-frontage lot: massed at the lot center, its `rotationY`
43
+ * set so the front faces the road (the renderer turns the whole building about `center`), floors
44
+ * rolled deterministically from the descriptor's `stories` and the lot's road/side/index.
45
+ */
46
+ function resolveFrontageBuildings(descriptor, along) {
47
+ const seed = descriptor.seed ?? "buildings";
48
+ const lots = deriveBuildingLots({
49
+ roads: along.roads,
50
+ footprint: descriptor.footprint,
51
+ spacing: descriptor.spacing,
52
+ maxLots: descriptor.count,
53
+ seed: `${seed}:lots`,
54
+ ...(along.setback === undefined ? {} : { setback: along.setback }),
55
+ ...(along.bothSides === undefined ? {} : { bothSides: along.bothSides }),
56
+ ...(along.area === undefined ? {} : { area: along.area }),
57
+ });
58
+ const [floorsMin, floorsMax] = descriptor.stories;
59
+ const bayWidth = DEFAULT_BUILDING_CONFIG.bayWidth;
60
+ return lots.map((lot, index) => buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, descriptor.storyHeight));
61
+ }
62
+ function buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, storyHeight) {
63
+ const lo = Math.min(floorsMin, floorsMax);
64
+ const hi = Math.max(floorsMin, floorsMax);
65
+ const floors = lo + Math.floor((hashU32(`${seed}:floors:${index}`) / 4294967296) * (hi - lo + 1));
66
+ const baysWide = Math.max(1, Math.floor(lot.footprint.w / bayWidth));
67
+ const baysDeep = Math.max(1, Math.floor(lot.footprint.d / bayWidth));
68
+ const building = generateBuilding({
69
+ id: `frontage-${index}`,
70
+ seed: `${seed}:${index}`,
71
+ center: lot.center,
72
+ floors,
73
+ baysWide,
74
+ baysDeep,
75
+ bayWidth,
76
+ floorHeight: storyHeight,
77
+ });
78
+ return { ...building, rotationY: lot.rotationY };
79
+ }
24
80
  const TERRAIN_SAMPLE_RESOLUTION = 16;
25
81
  function sampleTerrainHeights(descriptor) {
26
82
  const field = resolveTerrainField(descriptor);
@@ -291,6 +291,34 @@ export interface SkyEnvironmentConfig {
291
291
  /** Raymarched volumetric cloud layer over the sky dome. Off by default — omit for no clouds. */
292
292
  volumetricClouds?: VolumetricCloudsConfig;
293
293
  }
294
+ /** One road a `building({ along })` frontage lines with buildings: centerline + full width. */
295
+ export interface BuildingFrontageRoad {
296
+ /** Road centerline vertices in world XZ; at least two points. */
297
+ path: readonly EnvironmentVec2[];
298
+ /** Road full width in world units; lots set back from `width / 2`. */
299
+ width: number;
300
+ }
301
+ /**
302
+ * Street-aware placement for `building()`: instead of a grid scattered around `position`, lots are
303
+ * stepped along each road's frontage, offset by a consistent setback (a curb + sidewalk strip), and
304
+ * turned so every building FRONT faces its road. `footprint` (`w` = frontage width, `d` = depth) and
305
+ * `spacing` (along-road gap) are shared with the grid mode. Deterministic and bounded by `maxLots`.
306
+ */
307
+ export interface BuildingFrontageConfig {
308
+ /** Roads whose frontage is lined with lots. */
309
+ roads: readonly BuildingFrontageRoad[];
310
+ /** Sidewalk strip between the curb (`width / 2`) and the building front, world units. Default 3. */
311
+ setback?: number;
312
+ /** Place lots on both sides of each road. Default true. */
313
+ bothSides?: boolean;
314
+ /** Clip region; lots whose center falls outside are dropped. Omit to keep every lot. */
315
+ area?: {
316
+ center?: EnvironmentVec2;
317
+ halfExtents: EnvironmentVec2;
318
+ };
319
+ /** Hard cap on emitted lots so a long network stays bounded. Default 400. */
320
+ maxLots?: number;
321
+ }
294
322
  export interface BuildingEnvironmentConfig {
295
323
  count?: number;
296
324
  position?: EnvironmentVec2;
@@ -303,6 +331,11 @@ export interface BuildingEnvironmentConfig {
303
331
  /** Explicit per-part-kind hex colors; any part left unset falls back to the resolved `style` palette. */
304
332
  palette?: BuildingPaletteOverrides;
305
333
  seed?: string;
334
+ /**
335
+ * Street-aware placement: derive lots along the given roads' frontage (facing each street, set
336
+ * back by a sidewalk strip) instead of the grid around `position`. See {@link BuildingFrontageConfig}.
337
+ */
338
+ along?: BuildingFrontageConfig;
306
339
  }
307
340
  export type PadSize = readonly [number, number] | {
308
341
  radius: number;
@@ -344,7 +377,7 @@ export type OceanEnvironmentDescriptor = {
344
377
  } & Required<Pick<OceanEnvironmentConfig, "bounds" | "level" | "waveHeight" | "waveScale" | "waveSpeed" | "color">> & Pick<OceanEnvironmentConfig, "position" | "levelAt">;
345
378
  export type BuildingEnvironmentDescriptor = {
346
379
  kind: "building";
347
- } & Required<Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style">> & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette">;
380
+ } & Required<Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style">> & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along">;
348
381
  export type PadEnvironmentDescriptor = {
349
382
  kind: "pad";
350
383
  } & Required<Pick<PadEnvironmentConfig, "center" | "size" | "height" | "color">> & Pick<PadEnvironmentConfig, "rotationY" | "elevation">;
@@ -509,7 +542,7 @@ export declare function ocean(config?: OceanEnvironmentConfig): OceanEnvironment
509
542
  /** Declare a road ribbon for an `environment()` world; the shell drapes and renders it over the terrain. */
510
543
  export declare function road(config: RoadEnvironmentConfig): RoadEnvironmentDescriptor;
511
544
  export declare function island(config: TerrainIslandConfig): TerrainIslandDescriptor;
512
- /** Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. */
545
+ /** Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`. */
513
546
  export declare function building(config?: BuildingEnvironmentConfig): BuildingEnvironmentDescriptor;
514
547
  export declare function pad(config: PadEnvironmentConfig): PadEnvironmentDescriptor;
515
548
  /** Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain. */
@@ -228,11 +228,13 @@ export function island(config) {
228
228
  const { kind: _kind, ...descriptor } = terrain(terrainConfig);
229
229
  return { ...descriptor, kind: "island", origin };
230
230
  }
231
- /** Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. */
231
+ /** Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`. */
232
232
  export function building(config = {}) {
233
233
  return withOptional({
234
234
  kind: "building",
235
- count: config.count ?? 1,
235
+ // In frontage mode the road network sizes the district; `count` becomes an upper bound, so a
236
+ // caller that just says `along` gets a filled street rather than a single grid building.
237
+ count: config.count ?? (config.along === undefined ? 1 : config.along.maxLots ?? 400),
236
238
  footprint: config.footprint ?? { w: 8, d: 8 },
237
239
  stories: config.stories ?? [1, 4],
238
240
  storyHeight: config.storyHeight ?? 3,
@@ -242,6 +244,7 @@ export function building(config = {}) {
242
244
  ...(config.seed === undefined ? {} : { seed: config.seed }),
243
245
  ...(config.position === undefined ? {} : { position: config.position }),
244
246
  ...(config.palette === undefined ? {} : { palette: config.palette }),
247
+ ...(config.along === undefined ? {} : { along: config.along }),
245
248
  });
246
249
  }
247
250
  export function pad(config) {
@@ -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
  }
@@ -29,12 +29,29 @@ export interface RoadRibbonOptions {
29
29
  * into a mesh, and tests can assert on it directly.
30
30
  */
31
31
  export declare function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options?: RoadRibbonOptions): RoadRibbon;
32
+ /** A circular exclusion zone: dashes whose midpoint falls inside are dropped (e.g. junction patches). */
33
+ export interface DashExclusion {
34
+ center: RoadPoint;
35
+ radius: number;
36
+ }
37
+ /**
38
+ * Build a flat, ground-draped disc patch centered on `center` — the welded junction surface that
39
+ * covers crossing road ribbons so they read as one intersection instead of stacking/z-fighting.
40
+ * A triangle fan of `segments` sides (default 16), every vertex draped at `sampleHeight + elevation`.
41
+ * Pure geometry; the shell meshes the result with the asphalt material.
42
+ */
43
+ export declare function buildJunctionPatch(center: RoadPoint, radius: number, sampleHeight: (x: number, z: number) => number, options?: {
44
+ elevation?: number;
45
+ segments?: number;
46
+ }): RoadRibbon;
32
47
  /**
33
48
  * Split a centerline into dash sub-polylines for lane markings: `dashLength` of painted line,
34
49
  * `gapLength` of asphalt, repeated along the path's arc length. Feed each returned sub-path back
35
- * through {@link buildRoadRibbon} with a thin width to mesh the dashes.
50
+ * through {@link buildRoadRibbon} with a thin width to mesh the dashes. Pass `exclude` circles
51
+ * (junction patches) to interrupt the center line through intersections — any dash whose midpoint
52
+ * lands inside an exclusion is dropped.
36
53
  */
37
- export declare function dashSegments(path: readonly RoadPoint[], dashLength?: number, gapLength?: number): readonly (readonly RoadPoint[])[];
54
+ export declare function dashSegments(path: readonly RoadPoint[], dashLength?: number, gapLength?: number, exclude?: readonly DashExclusion[]): readonly (readonly RoadPoint[])[];
38
55
  /** Result of {@link nearestOnPath}: closest point on the centerline plus distance and tangent. */
39
56
  export interface RoadSample {
40
57
  /** Closest point on the centerline. */
@@ -102,12 +102,48 @@ export function buildRoadRibbon(path, width, sampleHeight, options = {}) {
102
102
  }
103
103
  return { positions, indices };
104
104
  }
105
+ /**
106
+ * Build a flat, ground-draped disc patch centered on `center` — the welded junction surface that
107
+ * covers crossing road ribbons so they read as one intersection instead of stacking/z-fighting.
108
+ * A triangle fan of `segments` sides (default 16), every vertex draped at `sampleHeight + elevation`.
109
+ * Pure geometry; the shell meshes the result with the asphalt material.
110
+ */
111
+ export function buildJunctionPatch(center, radius, sampleHeight, options = {}) {
112
+ const elevation = options.elevation ?? 0.08;
113
+ const segments = Math.max(3, Math.floor(options.segments ?? 16));
114
+ if (radius <= 0)
115
+ return { positions: new Float32Array(0), indices: new Uint32Array(0) };
116
+ const [cx, cz] = center;
117
+ // Center vertex + one ring vertex per segment.
118
+ const positions = new Float32Array((segments + 1) * 3);
119
+ positions[0] = cx;
120
+ positions[1] = sampleHeight(cx, cz) + elevation;
121
+ positions[2] = cz;
122
+ for (let i = 0; i < segments; i += 1) {
123
+ const angle = (i / segments) * Math.PI * 2;
124
+ const x = cx + Math.cos(angle) * radius;
125
+ const z = cz + Math.sin(angle) * radius;
126
+ const base = (i + 1) * 3;
127
+ positions[base] = x;
128
+ positions[base + 1] = sampleHeight(x, z) + elevation;
129
+ positions[base + 2] = z;
130
+ }
131
+ const indices = new Uint32Array(segments * 3);
132
+ for (let i = 0; i < segments; i += 1) {
133
+ indices[i * 3] = 0;
134
+ indices[i * 3 + 1] = i + 1;
135
+ indices[i * 3 + 2] = ((i + 1) % segments) + 1;
136
+ }
137
+ return { positions, indices };
138
+ }
105
139
  /**
106
140
  * Split a centerline into dash sub-polylines for lane markings: `dashLength` of painted line,
107
141
  * `gapLength` of asphalt, repeated along the path's arc length. Feed each returned sub-path back
108
- * through {@link buildRoadRibbon} with a thin width to mesh the dashes.
142
+ * through {@link buildRoadRibbon} with a thin width to mesh the dashes. Pass `exclude` circles
143
+ * (junction patches) to interrupt the center line through intersections — any dash whose midpoint
144
+ * lands inside an exclusion is dropped.
109
145
  */
110
- export function dashSegments(path, dashLength = 3, gapLength = 3) {
146
+ export function dashSegments(path, dashLength = 3, gapLength = 3, exclude = []) {
111
147
  if (path.length < 2 || dashLength <= 0 || gapLength < 0)
112
148
  return [];
113
149
  const cumulative = [0];
@@ -136,7 +172,28 @@ export function dashSegments(path, dashLength = 3, gapLength = 3) {
136
172
  const end = Math.min(total, start + dashLength);
137
173
  if (end - start < 1e-6)
138
174
  break;
139
- dashes.push([pointAt(start), pointAt(end)]);
175
+ const a = pointAt(start);
176
+ const b = pointAt(end);
177
+ if (exclude.length > 0) {
178
+ const mx = (a[0] + b[0]) / 2;
179
+ const mz = (a[1] + b[1]) / 2;
180
+ let excluded = false;
181
+ for (let e = 0; e < exclude.length; e += 1) {
182
+ const zone = exclude[e];
183
+ const dx = mx - zone.center[0];
184
+ const dz = mz - zone.center[1];
185
+ if (dx * dx + dz * dz <= zone.radius * zone.radius) {
186
+ excluded = true;
187
+ break;
188
+ }
189
+ }
190
+ if (excluded) {
191
+ if (period <= 0)
192
+ break;
193
+ continue;
194
+ }
195
+ }
196
+ dashes.push([a, b]);
140
197
  if (period <= 0)
141
198
  break;
142
199
  }