@jgengine/shell 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,20 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.24.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.24.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.21.0`; `@jgengine/github` is `0.11.0`.
34
+
35
+ ### Added
36
+
37
+ - `buildGroundCoverageMask`, `createGroundEdgeFade` and `applyGroundEdgeFade` (`@jgengine/shell/terrain`) fade any ground material out at the outer edge of a merged mesh footprint, leaving seams between layers inside it crisp.
38
+
39
+ ### Changed
40
+
41
+ - City studio ground (roads, junctions, block land, sidewalks, medians, drives, parks, crops, curbs, markings) now fades into the terrain where the town's ground ends instead of stopping at a hard edge. `meta.edgeFade: false` on the city volume keeps the old hard edge.
42
+
29
43
  ## 0.23.0
30
44
 
31
45
  ### Migrate
@@ -14,7 +14,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
14
14
  * All geometry derives from the document volume, so editor sliders re-render the district live and
15
15
  * games consume the same authored volume unchanged.
16
16
  */
17
- import { useEffect, useMemo, useRef, useState } from "react";
17
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
18
18
  import { useFrame } from "@react-three/fiber";
19
19
  import * as THREE from "three";
20
20
  import { roundPathCorners, buildIntersectionMarkings, buildRoadRibbon, buildTrimmedIntersections, dashSegments, GROUND_DECAL_LAYERS, } from "@jgengine/core/world/roads";
@@ -30,6 +30,7 @@ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
30
30
  import { getBuildingKit } from "../structures/buildingKitRegistry.js";
31
31
  import { createCityWindowMaterial } from "./cityWindowMaterial.js";
32
32
  import { createSurfaceRibbonMaterial } from "../terrain/surfaceRibbonMaterial.js";
33
+ import { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade } from "../terrain/groundEdgeFade.js";
33
34
  import { useTerrainQuality } from "../terrain/terrainQuality.js";
34
35
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
35
36
  import { junctionSurface } from "./cityJunctionSurface.js";
@@ -91,6 +92,22 @@ const CROP_LAYER = 0.42;
91
92
  const RACE_SURFACE_LAYER = 0.28;
92
93
  const RACE_PAINT_LAYER = 0.3;
93
94
  const BRIDGE_DECK_LIFT = 0.12;
95
+ /** Ground meshes bottom layer first; the index is each mesh's `renderOrder`, so blended layers draw under the ones above. */
96
+ const GROUND_LAYERS = [
97
+ "urban",
98
+ "asphalt",
99
+ "gravel",
100
+ "patches",
101
+ "curbs",
102
+ "markings",
103
+ "land",
104
+ "medians",
105
+ "sidewalks",
106
+ "parks",
107
+ "pavementDrives",
108
+ "gravelDrives",
109
+ "crops",
110
+ ];
94
111
  /** Compact carriageway-union — same seam as StreetGeometryPreview and playground `cityScene`. */
95
112
  const JUNCTION_OPTS = { curbReturnRadius: 2, apronMargin: 0.25, filletSegments: 6, elevation: ROAD_LAYER, maxSegmentLength: 2 };
96
113
  const SPECIES_PROXY = { broadleaf: "oak", conifer: "pine", palm: "palm", cypress: "cypress" };
@@ -1506,34 +1523,72 @@ function OneCity({ object, context }) {
1506
1523
  }, [race]);
1507
1524
  const quality = useTerrainQuality();
1508
1525
  const surfaceDetail = object.meta?.["surfaceDetail"] !== false;
1509
- const groundMaterials = useMemo(() => {
1510
- if (!surfaceDetail)
1526
+ // Every opaque ground layer fades out where the district's merged ground footprint meets bare
1527
+ // terrain; seams between layers inside it stay crisp. The urban sheet is left out of the
1528
+ // footprint because it already fades over its own street-distance reach.
1529
+ const edgeFadeOn = object.meta?.["edgeFade"] !== false;
1530
+ const edgeFade = useMemo(() => (edgeFadeOn ? createGroundEdgeFade() : null), [edgeFadeOn]);
1531
+ useEffect(() => () => edgeFade?.dispose(), [edgeFade]);
1532
+ const fadeWidth = Math.min(4, Math.max(1.5, (resolved?.rules.streetWidth ?? 3) * 0.6));
1533
+ const footprint = useMemo(() => {
1534
+ if (edgeFade === null || ground === null)
1511
1535
  return null;
1512
- const make = (surface, color, vertexColors, roughness, scale = 1) => createSurfaceRibbonMaterial({ surface, color, vertexColors, roughness, scale, halfWidth: 1, quality });
1536
+ const layers = GROUND_LAYERS.filter((layer) => layer !== "urban" && layer !== "curbs" && layer !== "markings").map((layer) => ground[layer]);
1537
+ return buildGroundCoverageMask(layers, { width: fadeWidth });
1538
+ }, [edgeFade, ground, fadeWidth]);
1539
+ useLayoutEffect(() => {
1540
+ edgeFade?.setMask(footprint, fadeWidth);
1541
+ }, [edgeFade, footprint, fadeWidth]);
1542
+ const groundMaterials = useMemo(() => {
1543
+ const make = (surface, color, vertexColors, roughness, scale = 1) => surfaceDetail
1544
+ ? createSurfaceRibbonMaterial({ surface, color, vertexColors, roughness, scale, halfWidth: 1, quality })
1545
+ : new THREE.MeshStandardMaterial({ color, vertexColors, roughness, metalness: 0, side: THREE.DoubleSide });
1513
1546
  const urban = make("dirt", "#ffffff", true, 1);
1514
1547
  urban.transparent = true;
1515
1548
  urban.depthWrite = false;
1516
- return {
1549
+ const materials = {
1517
1550
  urban,
1518
- land: make("dirt", "#ffffff", true, 1),
1519
1551
  asphalt: make("road", ASPHALT_COLOR, true, 0.96),
1520
1552
  gravel: make("gravel", GRAVEL_COLOR, false, 1),
1521
- sidewalks: make("pavement", SIDEWALK_COLOR, false, 1, 1.6),
1522
1553
  patches: make("road", ASPHALT_COLOR, true, 0.96),
1554
+ curbs: new THREE.MeshStandardMaterial({ color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }),
1555
+ markings: new THREE.MeshStandardMaterial({
1556
+ color: MARKING_COLOR,
1557
+ vertexColors: true,
1558
+ roughness: 0.8,
1559
+ metalness: 0,
1560
+ side: THREE.DoubleSide,
1561
+ polygonOffset: true,
1562
+ polygonOffsetFactor: -1,
1563
+ polygonOffsetUnits: -1,
1564
+ }),
1565
+ land: make("dirt", "#ffffff", true, 1),
1523
1566
  medians: make("grass", MEDIAN_COLOR, false, 1),
1567
+ sidewalks: make("pavement", SIDEWALK_COLOR, false, 1, 1.6),
1568
+ parks: make("ground", "#ffffff", true, 1),
1524
1569
  pavementDrives: make("concrete", PAVEMENT_COLOR, false, 1, 1.6),
1525
1570
  gravelDrives: make("gravel", GRAVEL_COLOR, false, 1),
1526
- parks: make("ground", "#ffffff", true, 1),
1527
1571
  crops: make("dirt", "#ffffff", true, 1),
1528
1572
  };
1529
- }, [surfaceDetail, quality]);
1573
+ if (edgeFade !== null) {
1574
+ for (const layer of GROUND_LAYERS)
1575
+ if (layer !== "urban")
1576
+ applyGroundEdgeFade(materials[layer], edgeFade);
1577
+ }
1578
+ return materials;
1579
+ }, [surfaceDetail, quality, edgeFade]);
1530
1580
  useEffect(() => () => {
1531
- for (const material of Object.values(groundMaterials ?? {}))
1581
+ for (const material of Object.values(groundMaterials))
1532
1582
  material.dispose();
1533
1583
  }, [groundMaterials]);
1534
1584
  if (resolved === null)
1535
1585
  return null;
1536
- return (_jsxs("group", { children: [ground?.urban != null ? (_jsx("mesh", { geometry: ground.urban, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.urban }), children: groundMaterials === null ? (_jsx("meshStandardMaterial", { vertexColors: true, transparent: true, depthWrite: false, roughness: 1, metalness: 0, side: THREE.DoubleSide })) : null })) : null, ground?.land != null ? (_jsx("mesh", { geometry: ground.land, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.land }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.asphalt }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravel }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.sidewalks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.patches }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, vertexColors: true, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.medians }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.pavementDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravelDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.parks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.crops }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1586
+ return (_jsxs("group", { children: [GROUND_LAYERS.map((layer, order) => {
1587
+ const geometry = ground?.[layer];
1588
+ if (geometry == null)
1589
+ return null;
1590
+ return (_jsx("mesh", { geometry: geometry, material: groundMaterials[layer], renderOrder: order, receiveShadow: layer !== "markings", castShadow: layer === "curbs" }, layer));
1591
+ }), bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1537
1592
  }
1538
1593
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1539
1594
  function InstancedBoxes({ matrices, color }) {
@@ -0,0 +1,67 @@
1
+ import * as THREE from "three";
2
+ /** Options for {@link buildGroundCoverageMask}. */
3
+ export interface GroundCoverageOptions {
4
+ /** Metres inside the outer edge over which ground goes from clear to opaque. */
5
+ width: number;
6
+ /** Gaps between layers narrower than twice this (metres) count as inside, not as an outer edge. Default 1.5. */
7
+ closeRadius?: number;
8
+ /** Finest cell size in metres. Default 0.5. */
9
+ cell?: number;
10
+ /** Upper bound on cells per axis; large districts get coarser cells. Default 640. */
11
+ maxCells?: number;
12
+ }
13
+ /**
14
+ * How far each point of a merged ground footprint lies inside its outer edge, as a grid over world
15
+ * XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more
16
+ * inside. Cell centres sit at `origin + (index + 0.5) * cell`.
17
+ */
18
+ export interface GroundCoverageMask {
19
+ data: Uint8Array;
20
+ columns: number;
21
+ rows: number;
22
+ originX: number;
23
+ originZ: number;
24
+ cell: number;
25
+ }
26
+ /**
27
+ * Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a
28
+ * {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged
29
+ * footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical
30
+ * faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
31
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
32
+ */
33
+ export declare function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null;
34
+ /** Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile. */
35
+ export interface GroundEdgeFade {
36
+ readonly uniforms: {
37
+ uJgFadeMask: {
38
+ value: THREE.Texture;
39
+ };
40
+ /** `(originX, originZ, columns * cell, rows * cell)`. */
41
+ uJgFadeBounds: {
42
+ value: THREE.Vector4;
43
+ };
44
+ /** Mask width in metres; scales the noise that breaks up the edge. */
45
+ uJgFadeWidth: {
46
+ value: number;
47
+ };
48
+ };
49
+ /** Swap in a new mask (`null` = fully opaque everywhere). The previous texture is disposed. */
50
+ setMask(mask: GroundCoverageMask | null, width: number): void;
51
+ dispose(): void;
52
+ }
53
+ /**
54
+ * Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
55
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
56
+ */
57
+ export declare function createGroundEdgeFade(): GroundEdgeFade;
58
+ /**
59
+ * Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge,
60
+ * opaque `width` metres inside, with a noise-broken line. Seams between layers inside the
61
+ * footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary
62
+ * melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending
63
+ * `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes
64
+ * with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
65
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
66
+ */
67
+ export declare function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M;
@@ -0,0 +1,234 @@
1
+ import * as THREE from "three";
2
+ const DIAGONAL = Math.SQRT2;
3
+ /** Two-pass chamfer distance (in cells) from every cell to the nearest seed cell. */
4
+ function chamfer(seed, columns, rows) {
5
+ const d = new Float32Array(columns * rows);
6
+ for (let i = 0; i < d.length; i += 1)
7
+ d[i] = seed[i] === 1 ? 0 : Infinity;
8
+ for (let j = 0; j < rows; j += 1) {
9
+ for (let i = 0; i < columns; i += 1) {
10
+ const k = j * columns + i;
11
+ let best = d[k];
12
+ if (i > 0)
13
+ best = Math.min(best, d[k - 1] + 1);
14
+ if (j > 0) {
15
+ best = Math.min(best, d[k - columns] + 1);
16
+ if (i > 0)
17
+ best = Math.min(best, d[k - columns - 1] + DIAGONAL);
18
+ if (i + 1 < columns)
19
+ best = Math.min(best, d[k - columns + 1] + DIAGONAL);
20
+ }
21
+ d[k] = best;
22
+ }
23
+ }
24
+ for (let j = rows - 1; j >= 0; j -= 1) {
25
+ for (let i = columns - 1; i >= 0; i -= 1) {
26
+ const k = j * columns + i;
27
+ let best = d[k];
28
+ if (i + 1 < columns)
29
+ best = Math.min(best, d[k + 1] + 1);
30
+ if (j + 1 < rows) {
31
+ best = Math.min(best, d[k + columns] + 1);
32
+ if (i + 1 < columns)
33
+ best = Math.min(best, d[k + columns + 1] + DIAGONAL);
34
+ if (i > 0)
35
+ best = Math.min(best, d[k + columns - 1] + DIAGONAL);
36
+ }
37
+ d[k] = best;
38
+ }
39
+ }
40
+ return d;
41
+ }
42
+ /**
43
+ * Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a
44
+ * {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged
45
+ * footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical
46
+ * faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
47
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
48
+ */
49
+ export function buildGroundCoverageMask(geometries, options) {
50
+ const width = Math.max(1e-3, options.width);
51
+ const closeRadius = Math.max(0, options.closeRadius ?? 1.5);
52
+ let minX = Infinity;
53
+ let minZ = Infinity;
54
+ let maxX = -Infinity;
55
+ let maxZ = -Infinity;
56
+ for (const geometry of geometries) {
57
+ const position = geometry?.getAttribute("position");
58
+ if (position === undefined)
59
+ continue;
60
+ for (let v = 0; v < position.count; v += 1) {
61
+ const x = position.getX(v);
62
+ const z = position.getZ(v);
63
+ if (x < minX)
64
+ minX = x;
65
+ if (x > maxX)
66
+ maxX = x;
67
+ if (z < minZ)
68
+ minZ = z;
69
+ if (z > maxZ)
70
+ maxZ = z;
71
+ }
72
+ }
73
+ if (!Number.isFinite(minX))
74
+ return null;
75
+ const maxCells = Math.max(8, Math.floor(options.maxCells ?? 640));
76
+ const pad = closeRadius + 2;
77
+ const spanX = maxX - minX + pad * 2;
78
+ const spanZ = maxZ - minZ + pad * 2;
79
+ const cell = Math.max(options.cell ?? 0.5, spanX / maxCells, spanZ / maxCells);
80
+ const columns = Math.max(2, Math.ceil(spanX / cell));
81
+ const rows = Math.max(2, Math.ceil(spanZ / cell));
82
+ const originX = minX - pad;
83
+ const originZ = minZ - pad;
84
+ const covered = new Uint8Array(columns * rows);
85
+ let any = false;
86
+ for (const geometry of geometries) {
87
+ const position = geometry?.getAttribute("position");
88
+ if (geometry == null || position === undefined)
89
+ continue;
90
+ const index = geometry.getIndex();
91
+ const count = index === null ? position.count : index.count;
92
+ for (let t = 0; t + 2 < count; t += 3) {
93
+ const a = index === null ? t : index.getX(t);
94
+ const b = index === null ? t + 1 : index.getX(t + 1);
95
+ const c = index === null ? t + 2 : index.getX(t + 2);
96
+ const ax = position.getX(a);
97
+ const az = position.getZ(a);
98
+ const bx = position.getX(b);
99
+ const bz = position.getZ(b);
100
+ const cx = position.getX(c);
101
+ const cz = position.getZ(c);
102
+ const area = (bx - ax) * (cz - az) - (bz - az) * (cx - ax);
103
+ if (Math.abs(area) < 1e-6)
104
+ continue;
105
+ const sign = area > 0 ? 1 : -1;
106
+ const i0 = Math.max(0, Math.floor((Math.min(ax, bx, cx) - originX) / cell - 0.5));
107
+ const i1 = Math.min(columns - 1, Math.ceil((Math.max(ax, bx, cx) - originX) / cell - 0.5));
108
+ const j0 = Math.max(0, Math.floor((Math.min(az, bz, cz) - originZ) / cell - 0.5));
109
+ const j1 = Math.min(rows - 1, Math.ceil((Math.max(az, bz, cz) - originZ) / cell - 0.5));
110
+ for (let j = j0; j <= j1; j += 1) {
111
+ const pz = originZ + (j + 0.5) * cell;
112
+ for (let i = i0; i <= i1; i += 1) {
113
+ const px = originX + (i + 0.5) * cell;
114
+ const e0 = ((bx - ax) * (pz - az) - (bz - az) * (px - ax)) * sign;
115
+ const e1 = ((cx - bx) * (pz - bz) - (cz - bz) * (px - bx)) * sign;
116
+ const e2 = ((ax - cx) * (pz - cz) - (az - cz) * (px - cx)) * sign;
117
+ if (e0 >= 0 && e1 >= 0 && e2 >= 0) {
118
+ covered[j * columns + i] = 1;
119
+ any = true;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ if (!any)
126
+ return null;
127
+ // Morphological closing: grow the footprint by `closeRadius`, then measure inward from the grown
128
+ // edge less that radius, so a narrow gap between two layers never reads as an outer edge.
129
+ const toCovered = chamfer(covered, columns, rows);
130
+ const outsideGrown = new Uint8Array(columns * rows);
131
+ for (let k = 0; k < outsideGrown.length; k += 1)
132
+ outsideGrown[k] = toCovered[k] * cell > closeRadius ? 1 : 0;
133
+ const toOutside = chamfer(outsideGrown, columns, rows);
134
+ const data = new Uint8Array(columns * rows);
135
+ for (let k = 0; k < data.length; k += 1) {
136
+ // Centre-to-centre distance overshoots the edge between two cells by half a cell.
137
+ const inside = (toOutside[k] - 0.5) * cell - closeRadius;
138
+ data[k] = Math.round(Math.max(0, Math.min(1, inside / width)) * 255);
139
+ }
140
+ return { data, columns, rows, originX, originZ, cell };
141
+ }
142
+ function opaqueTexture() {
143
+ const texture = new THREE.DataTexture(new Uint8Array([255]), 1, 1, THREE.RedFormat);
144
+ texture.needsUpdate = true;
145
+ return texture;
146
+ }
147
+ /**
148
+ * Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
149
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
150
+ */
151
+ export function createGroundEdgeFade() {
152
+ const uniforms = {
153
+ uJgFadeMask: { value: opaqueTexture() },
154
+ uJgFadeBounds: { value: new THREE.Vector4(-1e6, -1e6, 2e6, 2e6) },
155
+ uJgFadeWidth: { value: 1 },
156
+ };
157
+ return {
158
+ uniforms,
159
+ setMask(mask, width) {
160
+ uniforms.uJgFadeMask.value.dispose();
161
+ if (mask === null) {
162
+ uniforms.uJgFadeMask.value = opaqueTexture();
163
+ uniforms.uJgFadeBounds.value.set(-1e6, -1e6, 2e6, 2e6);
164
+ }
165
+ else {
166
+ const texture = new THREE.DataTexture(mask.data, mask.columns, mask.rows, THREE.RedFormat);
167
+ texture.magFilter = THREE.LinearFilter;
168
+ texture.minFilter = THREE.LinearFilter;
169
+ texture.wrapS = THREE.ClampToEdgeWrapping;
170
+ texture.wrapT = THREE.ClampToEdgeWrapping;
171
+ // One byte per texel: rows are not 4-byte aligned unless `columns` happens to be.
172
+ texture.unpackAlignment = 1;
173
+ texture.needsUpdate = true;
174
+ uniforms.uJgFadeMask.value = texture;
175
+ uniforms.uJgFadeBounds.value.set(mask.originX, mask.originZ, mask.columns * mask.cell, mask.rows * mask.cell);
176
+ }
177
+ uniforms.uJgFadeWidth.value = Math.max(1e-3, width);
178
+ },
179
+ dispose() {
180
+ uniforms.uJgFadeMask.value.dispose();
181
+ },
182
+ };
183
+ }
184
+ const FADE_FRAGMENT_GLSL = /* glsl */ `
185
+ {
186
+ float jgFadeIn = texture2D(uJgFadeMask, (vJgFadePos - uJgFadeBounds.xy) / uJgFadeBounds.zw).r;
187
+ // Two octaves of value noise keep the edge from reading as a traced outline.
188
+ float jgFadeN = jgFadeNoise(vJgFadePos / max(0.6, uJgFadeWidth * 1.2)) * 0.7 + jgFadeNoise(vJgFadePos / max(0.25, uJgFadeWidth * 0.3) + 17.3) * 0.3 - 0.5;
189
+ diffuseColor.a *= smoothstep(0.0, 1.0, (jgFadeIn - 0.15 + jgFadeN * 0.35) / 0.7);
190
+ }
191
+ `;
192
+ const FADE_NOISE_GLSL = /* glsl */ `
193
+ float jgFadeHash(vec2 p) {
194
+ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
195
+ }
196
+ float jgFadeNoise(vec2 p) {
197
+ vec2 i = floor(p);
198
+ vec2 f = fract(p);
199
+ vec2 u = f * f * (3.0 - 2.0 * f);
200
+ return mix(mix(jgFadeHash(i), jgFadeHash(i + vec2(1.0, 0.0)), u.x), mix(jgFadeHash(i + vec2(0.0, 1.0)), jgFadeHash(i + vec2(1.0, 1.0)), u.x), u.y);
201
+ }
202
+ `;
203
+ /**
204
+ * Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge,
205
+ * opaque `width` metres inside, with a noise-broken line. Seams between layers inside the
206
+ * footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary
207
+ * melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending
208
+ * `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes
209
+ * with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
210
+ * @capability ground-edge-fade fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
211
+ */
212
+ export function applyGroundEdgeFade(material, fade) {
213
+ const previousCompile = material.onBeforeCompile;
214
+ const previousKey = material.customProgramCacheKey;
215
+ material.transparent = true;
216
+ material.onBeforeCompile = (shader, renderer) => {
217
+ previousCompile.call(material, shader, renderer);
218
+ Object.assign(shader.uniforms, fade.uniforms);
219
+ shader.vertexShader = shader.vertexShader
220
+ .replace("#include <common>", "#include <common>\nvarying vec2 vJgFadePos;")
221
+ .replace("#include <begin_vertex>", "#include <begin_vertex>\nvJgFadePos = (modelMatrix * vec4(transformed, 1.0)).xz;");
222
+ shader.fragmentShader = shader.fragmentShader
223
+ .replace("#include <common>", `#include <common>
224
+ varying vec2 vJgFadePos;
225
+ uniform sampler2D uJgFadeMask;
226
+ uniform vec4 uJgFadeBounds;
227
+ uniform float uJgFadeWidth;
228
+ ${FADE_NOISE_GLSL}`)
229
+ .replace("#include <alphamap_fragment>", `#include <alphamap_fragment>\n${FADE_FRAGMENT_GLSL}`);
230
+ };
231
+ material.customProgramCacheKey = () => `${previousKey.call(material)}-jgedgefade`;
232
+ material.needsUpdate = true;
233
+ return material;
234
+ }
@@ -14,3 +14,4 @@ export { ALL_TERRAIN_SURFACE_CLASSES, createTerrainDetailMaterial, type TerrainD
14
14
  export { createTerrainPaintTextures, MAX_TERRAIN_PAINT_LAYERS, packTerrainPaintWeights, terrainSurfaceClass, terrainSurfaceClassIndex, terrainSurfaceRoughness, type TerrainPaintSlot, type TerrainPaintTextures, type TerrainPaintWeights, type TerrainSurfaceClass, } from "./terrainSurfaces.js";
15
15
  export { TerrainQualityContext, useTerrainQuality } from "./terrainQuality.js";
16
16
  export { createSurfaceRibbonMaterial, resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, type RibbonSurfaceStyle, type SurfaceRibbonMaterialOptions, } from "./surfaceRibbonMaterial.js";
17
+ export { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade, type GroundCoverageMask, type GroundCoverageOptions, type GroundEdgeFade, } from "./groundEdgeFade.js";
@@ -14,3 +14,4 @@ export { ALL_TERRAIN_SURFACE_CLASSES, createTerrainDetailMaterial, } from "./ter
14
14
  export { createTerrainPaintTextures, MAX_TERRAIN_PAINT_LAYERS, packTerrainPaintWeights, terrainSurfaceClass, terrainSurfaceClassIndex, terrainSurfaceRoughness, } from "./terrainSurfaces.js";
15
15
  export { TerrainQualityContext, useTerrainQuality } from "./terrainQuality.js";
16
16
  export { createSurfaceRibbonMaterial, resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, } from "./surfaceRibbonMaterial.js";
17
+ export { applyGroundEdgeFade, buildGroundCoverageMask, createGroundEdgeFade, } from "./groundEdgeFade.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/shell",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,9 +35,9 @@
35
35
  "test": "bun test src"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.23.0",
39
- "@jgengine/react": "^0.23.0",
40
- "@jgengine/ws": "^0.23.0"
38
+ "@jgengine/core": "^0.24.0",
39
+ "@jgengine/react": "^0.24.0",
40
+ "@jgengine/ws": "^0.24.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@react-three/drei": "^10.0.0",
@@ -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.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.24.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -232,7 +232,7 @@
232
232
 
233
233
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
234
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.23.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
235
+ - `VERSION` (const): const VERSION: "0.24.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
236
 
237
237
  ## @jgengine/core/runtime/adapter
238
238
 
@@ -2828,6 +2828,9 @@
2828
2828
  - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented
2829
2829
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2830
2830
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2831
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
2832
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
2833
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
2831
2834
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
2832
2835
  - `HeightfieldDisplaceOptions` (interface): interface HeightfieldDisplaceOptions — Options for {@link displaceHeightfieldGeometry}.
2833
2836
  - `HeightfieldRect` (interface): interface HeightfieldRect — XZ rectangle in world units — matches the shape of a terraform snapshot's `bounds`.
@@ -2861,6 +2864,9 @@
2861
2864
  - `TerrainSurfaceClass` (type): type TerrainSurfaceClass = "ground" | "grass" | "dirt" | "gravel" | "rock" | "sand" | "mud" | "snow" | "road" — Procedural ground response the terrain shader renders for a painted surface id.
2862
2865
  - `TerrainSurfaceColorOptions` (interface): interface TerrainSurfaceColorOptions — Slope/noise surface shading laid over the height lerp so untextured ground reads like terrain instead of a smooth two-tone gradient. All knobs are optional with sane defaults; every field is a pure function of vertex position (+ `seed`), so the coloring is stable across rebuilds and tests.
2863
2866
  - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions { low?: THREE.ColorRepresentation; high?: THREE.ColorRepresentation; waterline?: THREE.ColorRepresentation; waterlineHeight?: number } — ⚠ undocumented
2867
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
2868
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
2869
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
2864
2870
  - `createSurfaceRibbonMaterial` (function): function createSurfaceRibbonMaterial(options: SurfaceRibbonMaterialOptions): THREE.MeshStandardMaterial — A `MeshStandardMaterial` that shades a ground ribbon (path, road, sidewalk) with the terrain's own procedural surface functions, so a dirt track or asphalt strip matches painted ground: the authored colour varied by the surface class's grain, roughness and micro-normal, wheel ruts or tyre lanes, and (with `feather > 0`) a noise-warped edge that fades into the terrain. The geometry needs a {@link RIBBON_FRAME_ATTRIBUTE} attribute from {@link ribbonSurfaceFrame}; build it wider by {@link ribbonEdgeMargin} so the fade straddles the authored edge. The caller disposes the material.
2865
2871
  - `createTerrainPaintColorSampler` (function): function createTerrainPaintColorSampler(terrain: TerrainPaintSource): TerrainPaintColorSampler — Shares layer tint, opacity and linear weighted-color blending between editor and runtime ground. Paint lives on a coarse cell grid; when the source exposes that grid, each sample smoothly blends the four surrounding cell centres (unpainted cells contribute the caller's base color in `out`), so painted paths and clearings get soft edges instead of grid-aligned stair steps.
2866
2872
  - `createTerrainPaintTextures` (function): function createTerrainPaintTextures(weights: TerrainPaintWeights): TerrainPaintTextures — Uploads packed paint coverage ({@link packTerrainPaintWeights}) as two linear-filtered RGBA textures plus per-slot hue, surface class, roughness and pattern scale for the terrain surface material.
@@ -2893,6 +2899,9 @@
2893
2899
  - `GrassRange` (type): type GrassRange = number | readonly [min: number, max: number] — ⚠ undocumented
2894
2900
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2895
2901
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2902
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
2903
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
2904
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
2896
2905
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
2897
2906
  - `HeightfieldDisplaceOptions` (interface): interface HeightfieldDisplaceOptions — Options for {@link displaceHeightfieldGeometry}.
2898
2907
  - `HeightfieldRect` (interface): interface HeightfieldRect — XZ rectangle in world units — matches the shape of a terraform snapshot's `bounds`.
@@ -2926,6 +2935,9 @@
2926
2935
  - `TerrainSurfaceClass` (type): type TerrainSurfaceClass = "ground" | "grass" | "dirt" | "gravel" | "rock" | "sand" | "mud" | "snow" | "road" — Procedural ground response the terrain shader renders for a painted surface id.
2927
2936
  - `TerrainSurfaceColorOptions` (interface): interface TerrainSurfaceColorOptions — Slope/noise surface shading laid over the height lerp so untextured ground reads like terrain instead of a smooth two-tone gradient. All knobs are optional with sane defaults; every field is a pure function of vertex position (+ `seed`), so the coloring is stable across rebuilds and tests.
2928
2937
  - `TerrainVertexColorOptions` (interface): interface TerrainVertexColorOptions { low?: THREE.ColorRepresentation; high?: THREE.ColorRepresentation; waterline?: THREE.ColorRepresentation; waterlineHeight?: number } — ⚠ undocumented
2938
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
2939
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
2940
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
2929
2941
  - `createSurfaceRibbonMaterial` (function): function createSurfaceRibbonMaterial(options: SurfaceRibbonMaterialOptions): THREE.MeshStandardMaterial — A `MeshStandardMaterial` that shades a ground ribbon (path, road, sidewalk) with the terrain's own procedural surface functions, so a dirt track or asphalt strip matches painted ground: the authored colour varied by the surface class's grain, roughness and micro-normal, wheel ruts or tyre lanes, and (with `feather > 0`) a noise-warped edge that fades into the terrain. The geometry needs a {@link RIBBON_FRAME_ATTRIBUTE} attribute from {@link ribbonSurfaceFrame}; build it wider by {@link ribbonEdgeMargin} so the fade straddles the authored edge. The caller disposes the material.
2930
2942
  - `createTerrainPaintColorSampler` (function): function createTerrainPaintColorSampler(terrain: TerrainPaintSource): TerrainPaintColorSampler — Shares layer tint, opacity and linear weighted-color blending between editor and runtime ground. Paint lives on a coarse cell grid; when the source exposes that grid, each sample smoothly blends the four surrounding cell centres (unpainted cells contribute the caller's base color in `out`), so painted paths and clearings get soft edges instead of grid-aligned stair steps.
2931
2943
  - `createTerrainPaintTextures` (function): function createTerrainPaintTextures(weights: TerrainPaintWeights): TerrainPaintTextures — Uploads packed paint coverage ({@link packTerrainPaintWeights}) as two linear-filtered RGBA textures plus per-slot hue, surface class, roughness and pattern scale for the terrain surface material.
@@ -2991,6 +3003,15 @@
2991
3003
  - `GrassShaderUniforms` (interface): interface GrassShaderUniforms { uTime: THREE.IUniform<number>; uWindDirection: THREE.IUniform<THREE.Vector2>; uWindStrength: THREE.IUniform<number>; uWindSpeed: THREE.IUniform<number>; uWindGustScale: THREE.IUniform<number>; uWindFlutter: THREE.IUniform<number>; uColorB… — ⚠ undocumented
2992
3004
  - `GrassWindOptions` (interface): interface GrassWindOptions { direction?: readonly [x: number, z: number]; strength?: number; speed?: number; gustScale?: number; flutter?: number; layered?: boolean } — ⚠ undocumented
2993
3005
 
3006
+ ## @jgengine/shell/terrain/groundEdgeFade
3007
+
3008
+ - `GroundCoverageMask` (interface): interface GroundCoverageMask — How far each point of a merged ground footprint lies inside its outer edge, as a grid over world XZ: `data[row * columns + column]` is 0 on or outside the edge and 255 at `width` metres or more inside. Cell centres sit at `origin + (index + 0.5) * cell`.
3009
+ - `GroundCoverageOptions` (interface): interface GroundCoverageOptions — Options for {@link buildGroundCoverageMask}.
3010
+ - `GroundEdgeFade` (interface): interface GroundEdgeFade — Shared uniforms for {@link applyGroundEdgeFade}; `setMask` retargets every material using them without a recompile.
3011
+ - `applyGroundEdgeFade` (function): function applyGroundEdgeFade<M extends THREE.Material>(material: M, fade: GroundEdgeFade): M — Fade a ground material out at the outer edge of a {@link GroundCoverageMask}: clear on the edge, opaque `width` metres inside, with a noise-broken line. Seams between layers inside the footprint are untouched, so a district keeps crisp road/plaza edges while its outer boundary melts into the terrain. Turns on blending (depth writes stay on); give stacked layers ascending `renderOrder` so lower ones draw first. Chains any existing `onBeforeCompile`, so it composes with `createSurfaceRibbonMaterial` and plain `MeshStandardMaterial`s alike.
3012
+ - `buildGroundCoverageMask` (function): function buildGroundCoverageMask(geometries: readonly (THREE.BufferGeometry | null | undefined)[], options: GroundCoverageOptions): GroundCoverageMask | null — Rasterize the XZ footprint of ground meshes (roads, block land, parks…) into a {@link GroundCoverageMask}. Small gaps between layers are closed first, so only the merged footprint's outer edge fades: seams between layers inside it keep their full opacity. Vertical faces (curbs) add nothing. Returns `null` when the meshes cover nothing.
3013
+ - `createGroundEdgeFade` (function): function createGroundEdgeFade(): GroundEdgeFade — Uniforms for one faded footprint, shared by every ground material of one district. Starts fully opaque.
3014
+
2994
3015
  ## @jgengine/shell/terrain/heightfieldGeometry
2995
3016
 
2996
3017
  - `HeightfieldColorFn` (type): type HeightfieldColorFn = (x: number, z: number, height: number, out: THREE.Color) => void — Per-vertex color hook for {@link displaceHeightfieldGeometry}; write the tone into `out`.
@@ -266,6 +266,12 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
266
266
 
267
267
  - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"`
268
268
 
269
+ ## ground-edge-fade — fade merged ground layers into the terrain at their outer edge, keeping inner seams crisp
270
+
271
+ - `applyGroundEdgeFade` (function) · `import { applyGroundEdgeFade } from "@jgengine/shell/terrain"`
272
+ - `buildGroundCoverageMask` (function) · `import { buildGroundCoverageMask } from "@jgengine/shell/terrain"`
273
+ - `createGroundEdgeFade` (function) · `import { createGroundEdgeFade } from "@jgengine/shell/terrain"`
274
+
269
275
  ## heavy-models — models over the per-model triangle budget, for perf probes and smoke assertions
270
276
 
271
277
  - `heavyModels` (function) · `import { heavyModels } from "@jgengine/shell/render/modelLoad"`
@@ -48,8 +48,12 @@ noise-warped edge that fades into the terrain around the authored width; pavemen
48
48
  edge with tyre lanes and cracks. Tune with `meta.edgeSoftness` (0 = hard) and `meta.wear` (0..1);
49
49
  `meta.surfaceDetail: false` or `<AuthoredScene pathSurfaceDetail={false}>` keeps flat ribbons.
50
50
  `road({ surface })` picks the carriageway surface (default `"road"`, `false` for flat colour), and
51
- city studio ground takes `meta.surfaceDetail: false` on its volume. Custom ribbons use
52
- `createSurfaceRibbonMaterial` with `ribbonSurfaceFrame` from `@jgengine/shell/terrain`.
51
+ city studio ground takes `meta.surfaceDetail: false` on its volume. City ground layers fade out
52
+ where the district's merged footprint meets bare terrain, keeping seams between layers crisp;
53
+ `meta.edgeFade: false` on the volume keeps hard outer edges. Custom ribbons use
54
+ `createSurfaceRibbonMaterial` with `ribbonSurfaceFrame` from `@jgengine/shell/terrain`; custom
55
+ ground meshes get the same outer-edge fade from `buildGroundCoverageMask`, `createGroundEdgeFade`
56
+ and `applyGroundEdgeFade`.
53
57
 
54
58
  Custom ground consumers can use `createTerrainPaintColorSampler` from `@jgengine/shell/terrain`
55
59
  with a `TerrainPaintSource` (an editable terrain's layer/surface/weight channels). The returned